@lukoweb/apitogo 0.2.22 → 0.2.24
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/README.md +94 -94
- package/cli.js +13 -13
- package/client.d.ts +8 -8
- package/dist/cli/cli.js +561 -232
- package/dist/declarations/cli/platform/client/platform-api.d.ts +18 -0
- package/dist/declarations/config/validators/InputNavigationSchema.d.ts +7 -7
- package/dist/declarations/config/validators/ZudokuConfig.d.ts +1 -1
- package/docs/cli/commands.md +75 -75
- package/docs/cli/go-live.md +123 -123
- package/docs/cli/overview.md +91 -91
- package/docs/cli/setup.md +141 -141
- package/docs/cli/shape.md +127 -127
- package/docs/components/alert.mdx +130 -130
- package/docs/components/badge.mdx +70 -70
- package/docs/components/button.mdx +132 -132
- package/docs/components/callout.mdx +112 -112
- package/docs/components/card.mdx +104 -104
- package/docs/components/checkbox.mdx +72 -72
- package/docs/components/client-only.mdx +79 -79
- package/docs/components/code-tabs.mdx +179 -179
- package/docs/components/dialog.mdx +167 -167
- package/docs/components/head.mdx +200 -200
- package/docs/components/icons.mdx +27 -27
- package/docs/components/input.mdx +96 -96
- package/docs/components/label.mdx +86 -86
- package/docs/components/link.mdx +242 -242
- package/docs/components/markdown.mdx +151 -151
- package/docs/components/mermaid.mdx +81 -81
- package/docs/components/playground.mdx +85 -85
- package/docs/components/secret.mdx +79 -79
- package/docs/components/select.mdx +176 -176
- package/docs/components/shadcn.mdx +73 -73
- package/docs/components/slider.mdx +108 -108
- package/docs/components/slot.mdx +119 -119
- package/docs/components/stepper.mdx +138 -138
- package/docs/components/switch.mdx +96 -96
- package/docs/components/syntax-highlight.mdx +602 -602
- package/docs/components/textarea.mdx +78 -78
- package/docs/components/tooltip.mdx +195 -195
- package/docs/components/typography.mdx +61 -61
- package/docs/components.mdx +60 -60
- package/docs/concepts/auth-provider-api-identities.md +105 -105
- package/docs/configuration/ai-assistants.md +65 -65
- package/docs/configuration/api-catalog.md +109 -109
- package/docs/configuration/api-reference.md +397 -397
- package/docs/configuration/authentication-auth0.md +181 -181
- package/docs/configuration/authentication-azure-ad.md +245 -245
- package/docs/configuration/authentication-clerk.md +117 -117
- package/docs/configuration/authentication-firebase.md +69 -69
- package/docs/configuration/authentication-pingfederate.md +143 -143
- package/docs/configuration/authentication-supabase.md +233 -233
- package/docs/configuration/authentication.md +159 -159
- package/docs/configuration/build-configuration.mdx +147 -147
- package/docs/configuration/docs.md +282 -282
- package/docs/configuration/footer.mdx +118 -118
- package/docs/configuration/gateway.md +221 -221
- package/docs/configuration/llms.md +90 -90
- package/docs/configuration/make-config.mdx +124 -124
- package/docs/configuration/manifest.md +128 -128
- package/docs/configuration/navigation.mdx +421 -421
- package/docs/configuration/overview.md +166 -166
- package/docs/configuration/plans.md +153 -153
- package/docs/configuration/protected-routes.md +150 -150
- package/docs/configuration/search.md +164 -164
- package/docs/configuration/sentry.mdx +44 -44
- package/docs/configuration/site.md +124 -124
- package/docs/configuration/slots.mdx +125 -125
- package/docs/configuration/vite-config.md +18 -18
- package/docs/custom-plugins.md +285 -285
- package/docs/customization/colors-theme.mdx +278 -278
- package/docs/customization/fonts.md +110 -110
- package/docs/customization/landing-page.md +188 -188
- package/docs/deploy/apache-nginx.md +41 -41
- package/docs/deploy/apitogo.md +17 -17
- package/docs/deploy/cloudflare-pages.md +74 -74
- package/docs/deploy/direct-upload.md +17 -17
- package/docs/deploy/github-pages.md +49 -49
- package/docs/deploy/vercel.md +101 -101
- package/docs/deployment.md +9 -9
- package/docs/extending/events.md +124 -124
- package/docs/guides/custom-pages.md +110 -110
- package/docs/guides/environment-variables.md +94 -94
- package/docs/guides/managing-api-keys-and-identities.md +89 -89
- package/docs/guides/mermaid.mdx +70 -70
- package/docs/guides/navigation-rules.mdx +203 -203
- package/docs/guides/processors.mdx +234 -234
- package/docs/guides/static-files.md +55 -55
- package/docs/guides/transforming-examples.md +156 -156
- package/docs/guides/using-multiple-apis.md +87 -87
- package/docs/markdown/admonitions.md +128 -128
- package/docs/markdown/code-blocks.md +196 -196
- package/docs/markdown/frontmatter.md +173 -173
- package/docs/markdown/mdx.md +68 -68
- package/docs/markdown/overview.md +275 -275
- package/docs/mcp/go-live.md +100 -100
- package/docs/mcp/overview.md +76 -76
- package/docs/mcp/setup.md +173 -173
- package/docs/mcp/shape.md +77 -77
- package/docs/mcp/tools.md +102 -102
- package/docs/plugins.md +24 -24
- package/docs/quickstart.md +72 -72
- package/docs/writing.mdx +72 -72
- package/package.json +1 -1
- package/src/app/ZuploBuildConfig.ts +36 -36
- package/src/app/apitogoBrandTheme.ts +47 -47
- package/src/app/defaultTheme.css +77 -77
- package/src/app/demo-cdn.html +37 -37
- package/src/app/demo.html +21 -21
- package/src/app/demo.tsx +77 -77
- package/src/app/entry.client.tsx +107 -107
- package/src/app/entry.server.tsx +160 -160
- package/src/app/env.ts +40 -40
- package/src/app/main.css +400 -400
- package/src/app/main.tsx +167 -167
- package/src/app/polyfills.ts +47 -47
- package/src/app/processRoutes.tsx +51 -51
- package/src/app/sentry.ts +24 -24
- package/src/app/standalone.html +23 -23
- package/src/app/standalone.tsx +60 -60
- package/src/app/utils/createRedirectRoutes.ts +11 -11
- package/src/config/apitogo-config-core.ts +7 -3
- package/src/config/apitogo-config-loader.browser.ts +17 -17
- package/src/config/apitogo-config-loader.node.ts +88 -88
- package/src/config/apitogo-config-loader.ts +6 -6
- package/src/config/build-apitogo-config.ts +61 -61
- package/src/config/config.ts +74 -74
- package/src/config/create-plugin.ts +71 -71
- package/src/config/default-landing-content.ts +149 -149
- package/src/config/file-exists.ts +6 -6
- package/src/config/gateway-limits.ts +128 -128
- package/src/config/landing-manifest.ts +11 -11
- package/src/config/landing-openapi-showcase.ts +124 -124
- package/src/config/loader.ts +375 -375
- package/src/config/local-manifest.ts +545 -545
- package/src/config/resolve-modules.ts +354 -354
- package/src/config/v2/__fixtures__/scenario-a-docs-only.json +6 -6
- package/src/config/v2/__fixtures__/scenario-b-managed-origin.json +14 -14
- package/src/config/v2/__fixtures__/scenario-c-proxy-oidc.json +15 -15
- package/src/config/v2/__fixtures__/scenario-h-full-stack.json +28 -28
- package/src/config/v2/docs-navigation.ts +72 -72
- package/src/config/v2/errors.ts +42 -42
- package/src/config/v2/gateway-reference.ts +220 -220
- package/src/config/v2/gateway-request.ts +123 -123
- package/src/config/v2/home-redirect.ts +98 -98
- package/src/config/v2/index.ts +126 -126
- package/src/config/v2/init.ts +273 -273
- package/src/config/v2/layout.ts +237 -237
- package/src/config/v2/ledger.ts +235 -235
- package/src/config/v2/link-directory.ts +34 -0
- package/src/config/v2/load.ts +70 -70
- package/src/config/v2/manifest.ts +452 -452
- package/src/config/v2/parse.ts +214 -214
- package/src/config/v2/paths.ts +54 -54
- package/src/config/v2/portal-profile.ts +88 -88
- package/src/config/v2/resolve.ts +188 -188
- package/src/config/v2/rules.ts +714 -714
- package/src/config/v2/schema.ts +245 -245
- package/src/config/v2/services.ts +663 -663
- package/src/config/v2/workspace.ts +3 -2
- package/src/config/v2/wrapper.ts +24 -2
- package/src/config/validators/BuildSchema.ts +95 -95
- package/src/config/validators/HeaderNavigationSchema.ts +44 -44
- package/src/config/validators/InputNavigationSchema.test-d.ts +178 -178
- package/src/config/validators/InputNavigationSchema.ts +206 -206
- package/src/config/validators/ModulesSchema.ts +502 -502
- package/src/config/validators/NavigationSchema.ts +352 -352
- package/src/config/validators/ProtectedRoutesSchema.ts +35 -35
- package/src/config/validators/ZudokuConfig.ts +792 -792
- package/src/config/validators/auth.ts +3 -3
- package/src/config/validators/reason-codes.ts +7 -7
- package/src/env.d.ts +11 -11
- package/src/index.ts +83 -83
- package/src/lib/MissingIcon.tsx +22 -22
- package/src/lib/assets/language-icons/bun.tsx +50 -50
- package/src/lib/assets/language-icons/c.tsx +31 -31
- package/src/lib/assets/language-icons/commonlisp.tsx +22 -22
- package/src/lib/assets/language-icons/cpp.tsx +35 -35
- package/src/lib/assets/language-icons/csharp.tsx +35 -35
- package/src/lib/assets/language-icons/css.tsx +36 -36
- package/src/lib/assets/language-icons/dart.tsx +39 -39
- package/src/lib/assets/language-icons/elixir.tsx +19 -19
- package/src/lib/assets/language-icons/go.tsx +19 -19
- package/src/lib/assets/language-icons/graphql.tsx +19 -19
- package/src/lib/assets/language-icons/html.tsx +24 -24
- package/src/lib/assets/language-icons/java.tsx +35 -35
- package/src/lib/assets/language-icons/javascript.tsx +11 -11
- package/src/lib/assets/language-icons/json.tsx +19 -19
- package/src/lib/assets/language-icons/kotlin.tsx +30 -30
- package/src/lib/assets/language-icons/markdown.tsx +19 -19
- package/src/lib/assets/language-icons/mdx.tsx +23 -23
- package/src/lib/assets/language-icons/npm.tsx +15 -15
- package/src/lib/assets/language-icons/objectivec.tsx +23 -23
- package/src/lib/assets/language-icons/ocaml.tsx +34 -34
- package/src/lib/assets/language-icons/php.tsx +19 -19
- package/src/lib/assets/language-icons/pnpm.tsx +32 -32
- package/src/lib/assets/language-icons/powershell.tsx +27 -27
- package/src/lib/assets/language-icons/python.tsx +23 -23
- package/src/lib/assets/language-icons/react.tsx +21 -21
- package/src/lib/assets/language-icons/ruby.tsx +19 -19
- package/src/lib/assets/language-icons/rust.tsx +19 -19
- package/src/lib/assets/language-icons/scala.tsx +19 -19
- package/src/lib/assets/language-icons/shell.tsx +19 -19
- package/src/lib/assets/language-icons/swift.tsx +19 -19
- package/src/lib/assets/language-icons/toml.tsx +23 -23
- package/src/lib/assets/language-icons/typescript.tsx +23 -23
- package/src/lib/assets/language-icons/xml.tsx +19 -19
- package/src/lib/assets/language-icons/yaml.tsx +23 -23
- package/src/lib/assets/language-icons/yarn.tsx +17 -17
- package/src/lib/assets/language-icons/zig.tsx +32 -32
- package/src/lib/auth/issuer.ts +34 -34
- package/src/lib/authentication/AuthenticationPlugin.tsx +33 -33
- package/src/lib/authentication/auth-header-nav.ts +46 -46
- package/src/lib/authentication/authentication.ts +51 -51
- package/src/lib/authentication/components/CallbackHandler.tsx +45 -45
- package/src/lib/authentication/components/OAuthErrorPage.tsx +197 -197
- package/src/lib/authentication/components/ProductionUnlockPage.tsx +26 -26
- package/src/lib/authentication/components/SignIn.tsx +46 -46
- package/src/lib/authentication/components/SignOut.tsx +15 -15
- package/src/lib/authentication/components/SignUp.tsx +48 -48
- package/src/lib/authentication/constants.ts +1 -1
- package/src/lib/authentication/errors.ts +34 -34
- package/src/lib/authentication/header-nav-filter.ts +35 -35
- package/src/lib/authentication/hook.ts +141 -141
- package/src/lib/authentication/providers/auth0.tsx +91 -91
- package/src/lib/authentication/providers/azureb2c.tsx +223 -223
- package/src/lib/authentication/providers/clerk.tsx +224 -224
- package/src/lib/authentication/providers/dev-portal-auth-pages.tsx +602 -602
- package/src/lib/authentication/providers/dev-portal-constants.ts +38 -38
- package/src/lib/authentication/providers/dev-portal-demo.ts +151 -151
- package/src/lib/authentication/providers/dev-portal-utils.ts +279 -279
- package/src/lib/authentication/providers/dev-portal.tsx +448 -448
- package/src/lib/authentication/providers/firebase.tsx +486 -486
- package/src/lib/authentication/providers/openid.tsx +516 -516
- package/src/lib/authentication/providers/supabase.tsx +412 -412
- package/src/lib/authentication/providers/util.ts +26 -26
- package/src/lib/authentication/state.ts +180 -180
- package/src/lib/authentication/ui/AuthCard.tsx +7 -7
- package/src/lib/authentication/ui/EmailLinkCallbackUi.tsx +129 -129
- package/src/lib/authentication/ui/EmailLinkSentUi.tsx +105 -105
- package/src/lib/authentication/ui/EmailLinkSignInUi.tsx +100 -100
- package/src/lib/authentication/ui/EmailVerificationUi.tsx +128 -128
- package/src/lib/authentication/ui/ZudokuAuthUi.tsx +620 -620
- package/src/lib/authentication/ui/icons/Apple.tsx +10 -10
- package/src/lib/authentication/ui/icons/Facebook.tsx +15 -15
- package/src/lib/authentication/ui/icons/Github.tsx +16 -16
- package/src/lib/authentication/ui/icons/Google.tsx +16 -16
- package/src/lib/authentication/ui/icons/Microsoft.tsx +12 -12
- package/src/lib/authentication/ui/icons/X.tsx +10 -10
- package/src/lib/authentication/utils/relativeRedirectUrl.ts +16 -16
- package/src/lib/components/AiAssistantMenuItems.tsx +102 -102
- package/src/lib/components/AnchorLink.tsx +24 -24
- package/src/lib/components/Autocomplete.tsx +123 -123
- package/src/lib/components/Banner.tsx +52 -52
- package/src/lib/components/Bootstrap.tsx +77 -77
- package/src/lib/components/BuildCheck.tsx +87 -87
- package/src/lib/components/CategoryHeading.tsx +19 -19
- package/src/lib/components/ClientOnly.tsx +19 -19
- package/src/lib/components/DeveloperHint.tsx +39 -39
- package/src/lib/components/ErrorPage.tsx +26 -26
- package/src/lib/components/Footer.tsx +146 -146
- package/src/lib/components/Framed.tsx +51 -51
- package/src/lib/components/Header.tsx +261 -261
- package/src/lib/components/HeaderAuthActions.tsx +32 -32
- package/src/lib/components/HeaderNavigation.tsx +176 -176
- package/src/lib/components/Heading.tsx +84 -84
- package/src/lib/components/InlineCode.tsx +15 -15
- package/src/lib/components/LandingLayout.tsx +27 -27
- package/src/lib/components/LanguageIcon.tsx +199 -199
- package/src/lib/components/Layout.tsx +59 -59
- package/src/lib/components/Main.tsx +52 -52
- package/src/lib/components/Markdown.tsx +55 -55
- package/src/lib/components/Mermaid.tsx +74 -74
- package/src/lib/components/Meta.tsx +46 -46
- package/src/lib/components/MobileTopNavigation.tsx +283 -283
- package/src/lib/components/MultiSelect.tsx +86 -86
- package/src/lib/components/NotFoundPage.tsx +37 -37
- package/src/lib/components/PageProgress.tsx +28 -28
- package/src/lib/components/PagefindSearchMeta.tsx +14 -14
- package/src/lib/components/Pagination.tsx +44 -44
- package/src/lib/components/PathRenderer.tsx +61 -61
- package/src/lib/components/PluginHeads.tsx +17 -17
- package/src/lib/components/Search.tsx +70 -70
- package/src/lib/components/SiteTitle.tsx +58 -58
- package/src/lib/components/Slot.tsx +65 -65
- package/src/lib/components/Spinner.tsx +5 -5
- package/src/lib/components/StatusPage.tsx +96 -96
- package/src/lib/components/ThemeSwitch.tsx +99 -99
- package/src/lib/components/TopNavigation.tsx +90 -90
- package/src/lib/components/Typography.tsx +12 -12
- package/src/lib/components/Zudoku.tsx +105 -105
- package/src/lib/components/cache.ts +27 -27
- package/src/lib/components/context/RenderContext.ts +11 -11
- package/src/lib/components/context/RouterEventsEmitter.tsx +19 -19
- package/src/lib/components/context/SlotProvider.tsx +149 -149
- package/src/lib/components/context/ViewportAnchorContext.tsx +137 -137
- package/src/lib/components/context/ZudokuContext.ts +140 -140
- package/src/lib/components/context/ZudokuProvider.tsx +20 -20
- package/src/lib/components/context/ZudokuReactContext.tsx +17 -17
- package/src/lib/components/index.ts +36 -36
- package/src/lib/components/navigation/Navigation.tsx +51 -51
- package/src/lib/components/navigation/NavigationBadge.tsx +48 -48
- package/src/lib/components/navigation/NavigationCategory.tsx +157 -157
- package/src/lib/components/navigation/NavigationFilterContext.tsx +28 -28
- package/src/lib/components/navigation/NavigationFilterInput.tsx +35 -35
- package/src/lib/components/navigation/NavigationItem.tsx +181 -181
- package/src/lib/components/navigation/NavigationWrapper.tsx +49 -49
- package/src/lib/components/navigation/PoweredByZudoku.tsx +39 -39
- package/src/lib/components/navigation/Toc.tsx +137 -137
- package/src/lib/components/navigation/ZudokuLogo.tsx +25 -25
- package/src/lib/components/navigation/ZuploLogo.tsx +14 -14
- package/src/lib/components/navigation/utils.ts +234 -234
- package/src/lib/core/RouteGuard.tsx +260 -260
- package/src/lib/core/ZudokuContext.ts +272 -272
- package/src/lib/core/plugins.ts +139 -139
- package/src/lib/core/react-query.ts +1 -1
- package/src/lib/core/router.ts +1 -1
- package/src/lib/core/transform-config.ts +67 -67
- package/src/lib/demo/DemoAnnouncement.tsx +17 -17
- package/src/lib/errors/ErrorAlert.tsx +28 -28
- package/src/lib/errors/ErrorMessage.tsx +50 -50
- package/src/lib/errors/RouterError.tsx +18 -18
- package/src/lib/errors/ServerError.tsx +5 -5
- package/src/lib/errors/TopLevelError.tsx +6 -6
- package/src/lib/hooks/index.ts +13 -13
- package/src/lib/hooks/useEvent.ts +41 -41
- package/src/lib/hooks/useHighlighter.ts +15 -15
- package/src/lib/hooks/useHotkey.ts +70 -70
- package/src/lib/icons.ts +2 -2
- package/src/lib/navigation/applyRules.ts +127 -127
- package/src/lib/navigation/pathMatcher.ts +81 -81
- package/src/lib/oas/graphql/circular.ts +69 -69
- package/src/lib/oas/graphql/constants.ts +2 -2
- package/src/lib/oas/graphql/index.ts +791 -791
- package/src/lib/oas/parser/dereference/index.ts +84 -84
- package/src/lib/oas/parser/dereference/resolveRef.ts +32 -32
- package/src/lib/oas/parser/index.ts +129 -129
- package/src/lib/oas/parser/schemas/v3.0.json +1489 -1489
- package/src/lib/oas/parser/schemas/v3.1.json +1298 -1298
- package/src/lib/oas/parser/upgrade/index.ts +174 -174
- package/src/lib/plugins/api-catalog/Catalog.tsx +80 -80
- package/src/lib/plugins/api-catalog/index.tsx +119 -119
- package/src/lib/plugins/api-keys/CreateApiKey.tsx +128 -128
- package/src/lib/plugins/api-keys/CreateApiKeyDialog.tsx +49 -49
- package/src/lib/plugins/api-keys/ProtectedRoute.tsx +33 -33
- package/src/lib/plugins/api-keys/SettingsApiKeys.tsx +72 -72
- package/src/lib/plugins/api-keys/index.tsx +274 -274
- package/src/lib/plugins/api-keys/settings/ApiKeyItem.tsx +342 -342
- package/src/lib/plugins/api-keys/settings/ApiKeyList.tsx +67 -67
- package/src/lib/plugins/api-keys/settings/RevealApiKey.tsx +127 -127
- package/src/lib/plugins/custom-pages/index.tsx +32 -32
- package/src/lib/plugins/markdown/MdxPage.tsx +260 -260
- package/src/lib/plugins/markdown/assets/ChatGPTLogo.tsx +11 -11
- package/src/lib/plugins/markdown/assets/ClaudeLogo.tsx +19 -19
- package/src/lib/plugins/markdown/index.tsx +82 -82
- package/src/lib/plugins/openapi/ApiHeader.tsx +96 -96
- package/src/lib/plugins/openapi/CollapsibleCode.tsx +88 -88
- package/src/lib/plugins/openapi/ColorizedParam.tsx +115 -115
- package/src/lib/plugins/openapi/DownloadSchemaButton.tsx +94 -94
- package/src/lib/plugins/openapi/Endpoint.tsx +85 -85
- package/src/lib/plugins/openapi/GeneratedExampleSidecarBox.tsx +52 -52
- package/src/lib/plugins/openapi/MCPEndpoint.tsx +273 -273
- package/src/lib/plugins/openapi/OasProvider.tsx +74 -74
- package/src/lib/plugins/openapi/OperationList.tsx +275 -275
- package/src/lib/plugins/openapi/OperationListItem.tsx +183 -183
- package/src/lib/plugins/openapi/ParamInfos.tsx +96 -96
- package/src/lib/plugins/openapi/ParameterList.tsx +53 -53
- package/src/lib/plugins/openapi/ParameterListItem.tsx +161 -161
- package/src/lib/plugins/openapi/PlaygroundDialogWrapper.tsx +67 -67
- package/src/lib/plugins/openapi/RequestBodySidecarBox.tsx +63 -63
- package/src/lib/plugins/openapi/ResponsesSidecarBox.tsx +120 -120
- package/src/lib/plugins/openapi/SchemaInfo.tsx +344 -344
- package/src/lib/plugins/openapi/SchemaList.tsx +113 -113
- package/src/lib/plugins/openapi/Sidecar.tsx +314 -314
- package/src/lib/plugins/openapi/SidecarBox.tsx +57 -57
- package/src/lib/plugins/openapi/SidecarExamples.tsx +177 -177
- package/src/lib/plugins/openapi/SimpleSelect.tsx +46 -46
- package/src/lib/plugins/openapi/client/GraphQLClient.tsx +73 -73
- package/src/lib/plugins/openapi/client/GraphQLContext.tsx +16 -16
- package/src/lib/plugins/openapi/client/createServer.ts +35 -35
- package/src/lib/plugins/openapi/client/useCreateQuery.ts +44 -44
- package/src/lib/plugins/openapi/components/ConstValue.tsx +24 -24
- package/src/lib/plugins/openapi/components/EnumValues.tsx +58 -58
- package/src/lib/plugins/openapi/components/NonHighlightedCode.tsx +22 -22
- package/src/lib/plugins/openapi/components/ResponseContent.tsx +117 -117
- package/src/lib/plugins/openapi/components/SelectOnClick.tsx +29 -29
- package/src/lib/plugins/openapi/context.tsx +16 -16
- package/src/lib/plugins/openapi/graphql/fragment-masking.ts +104 -104
- package/src/lib/plugins/openapi/graphql/gql.ts +95 -95
- package/src/lib/plugins/openapi/graphql/graphql.ts +835 -835
- package/src/lib/plugins/openapi/graphql/index.ts +2 -2
- package/src/lib/plugins/openapi/index.tsx +221 -221
- package/src/lib/plugins/openapi/interfaces.ts +100 -100
- package/src/lib/plugins/openapi/playground/BodyPanel.tsx +274 -274
- package/src/lib/plugins/openapi/playground/CollapsibleHeader.tsx +51 -51
- package/src/lib/plugins/openapi/playground/ExamplesDropdown.tsx +60 -60
- package/src/lib/plugins/openapi/playground/Headers.tsx +194 -194
- package/src/lib/plugins/openapi/playground/IdentityDialog.tsx +75 -75
- package/src/lib/plugins/openapi/playground/IdentitySelector.tsx +38 -38
- package/src/lib/plugins/openapi/playground/ParamsGrid.tsx +45 -45
- package/src/lib/plugins/openapi/playground/PathParams.tsx +53 -53
- package/src/lib/plugins/openapi/playground/Playground.tsx +602 -602
- package/src/lib/plugins/openapi/playground/PlaygroundDialog.tsx +62 -62
- package/src/lib/plugins/openapi/playground/QueryParams.tsx +151 -151
- package/src/lib/plugins/openapi/playground/RequestLoginDialog.tsx +70 -70
- package/src/lib/plugins/openapi/playground/Spinner.tsx +87 -87
- package/src/lib/plugins/openapi/playground/createUrl.ts +25 -25
- package/src/lib/plugins/openapi/playground/fileUtils.ts +36 -36
- package/src/lib/plugins/openapi/playground/rememberedIdentity.ts +26 -26
- package/src/lib/plugins/openapi/playground/request-panel/MultipartField.tsx +91 -91
- package/src/lib/plugins/openapi/playground/request-panel/UrlPath.tsx +31 -31
- package/src/lib/plugins/openapi/playground/request-panel/UrlQueryParams.tsx +29 -29
- package/src/lib/plugins/openapi/playground/request-panel/useKeyValueFieldManager.ts +340 -340
- package/src/lib/plugins/openapi/playground/result-panel/AudioPlayer.tsx +50 -50
- package/src/lib/plugins/openapi/playground/result-panel/ResponseStatusBar.tsx +117 -117
- package/src/lib/plugins/openapi/playground/result-panel/ResponseTab.tsx +355 -355
- package/src/lib/plugins/openapi/playground/result-panel/ResultPanel.tsx +106 -106
- package/src/lib/plugins/openapi/playground/result-panel/convertToTypes.ts +36 -36
- package/src/lib/plugins/openapi/playground/serializeQueryParams.ts +125 -125
- package/src/lib/plugins/openapi/playground/useRememberSkipLoginDialog.tsx +23 -23
- package/src/lib/plugins/openapi/processors/injectApitogoGatewayServer.ts +36 -36
- package/src/lib/plugins/openapi/processors/removeExtensions.ts +29 -29
- package/src/lib/plugins/openapi/processors/removeParameters.ts +103 -103
- package/src/lib/plugins/openapi/processors/removePaths.ts +57 -57
- package/src/lib/plugins/openapi/processors/traverse.ts +1 -1
- package/src/lib/plugins/openapi/schema/SchemaExampleAndDefault.tsx +39 -39
- package/src/lib/plugins/openapi/schema/SchemaPropertyItem.tsx +189 -189
- package/src/lib/plugins/openapi/schema/SchemaView.tsx +299 -299
- package/src/lib/plugins/openapi/schema/UnionView.tsx +132 -132
- package/src/lib/plugins/openapi/schema/union-helpers.ts +123 -123
- package/src/lib/plugins/openapi/schema/utils.ts +49 -49
- package/src/lib/plugins/openapi/state.ts +36 -36
- package/src/lib/plugins/openapi/util/buildTagCategories.ts +54 -54
- package/src/lib/plugins/openapi/util/createHttpSnippet.ts +135 -135
- package/src/lib/plugins/openapi/util/createNavigationCategory.tsx +39 -39
- package/src/lib/plugins/openapi/util/generateSchemaExample.ts +191 -191
- package/src/lib/plugins/openapi/util/getRoutes.tsx +265 -265
- package/src/lib/plugins/openapi/util/methodColorMap.tsx +11 -11
- package/src/lib/plugins/openapi/util/methodToColor.ts +27 -27
- package/src/lib/plugins/openapi/util/sanitizeMarkdownForMetatag.tsx +32 -32
- package/src/lib/plugins/openapi/util/useWarmupSchema.ts +23 -23
- package/src/lib/plugins/search-inkeep/index.tsx +108 -108
- package/src/lib/plugins/search-inkeep/inkeep.ts +24 -24
- package/src/lib/plugins/search-pagefind/IndexingDialog.tsx +162 -162
- package/src/lib/plugins/search-pagefind/PagefindSearch.tsx +202 -202
- package/src/lib/plugins/search-pagefind/ResultList.tsx +122 -122
- package/src/lib/plugins/search-pagefind/get-results.tsx +74 -74
- package/src/lib/plugins/search-pagefind/index.tsx +21 -21
- package/src/lib/plugins/search-pagefind/types.ts +118 -118
- package/src/lib/shiki-constants.ts +24 -24
- package/src/lib/shiki.ts +179 -179
- package/src/lib/testing/index.tsx +147 -147
- package/src/lib/ui/Accordion.tsx +55 -55
- package/src/lib/ui/ActionButton.tsx +21 -21
- package/src/lib/ui/Alert.tsx +84 -84
- package/src/lib/ui/AlertDialog.tsx +195 -195
- package/src/lib/ui/AspectRatio.tsx +5 -5
- package/src/lib/ui/Badge.tsx +48 -48
- package/src/lib/ui/Breadcrumb.tsx +115 -115
- package/src/lib/ui/Button.tsx +65 -65
- package/src/lib/ui/ButtonGroup.tsx +80 -80
- package/src/lib/ui/Callout.tsx +113 -113
- package/src/lib/ui/Card.tsx +95 -95
- package/src/lib/ui/Carousel.tsx +259 -259
- package/src/lib/ui/Checkbox.tsx +28 -28
- package/src/lib/ui/CodeBlock.tsx +92 -92
- package/src/lib/ui/CodeTabPanel.tsx +13 -13
- package/src/lib/ui/CodeTabs.tsx +161 -161
- package/src/lib/ui/Collapsible.tsx +31 -31
- package/src/lib/ui/Command.tsx +191 -191
- package/src/lib/ui/Dialog.tsx +138 -138
- package/src/lib/ui/DismissibleAlert.tsx +74 -74
- package/src/lib/ui/Drawer.tsx +121 -121
- package/src/lib/ui/DropdownMenu.tsx +254 -254
- package/src/lib/ui/EmbeddedCodeBlock.tsx +99 -99
- package/src/lib/ui/Form.tsx +176 -176
- package/src/lib/ui/Frame.tsx +81 -81
- package/src/lib/ui/HoverCard.tsx +26 -26
- package/src/lib/ui/Input.tsx +23 -23
- package/src/lib/ui/InputGroup.tsx +168 -168
- package/src/lib/ui/Item.tsx +213 -213
- package/src/lib/ui/Kbd.tsx +28 -28
- package/src/lib/ui/Label.tsx +23 -23
- package/src/lib/ui/NativeSelect.tsx +48 -48
- package/src/lib/ui/NavigationMenu.tsx +169 -169
- package/src/lib/ui/Pagination.tsx +115 -115
- package/src/lib/ui/Popover.tsx +28 -28
- package/src/lib/ui/Progress.tsx +25 -25
- package/src/lib/ui/RadioGroup.tsx +41 -41
- package/src/lib/ui/ReactComponentDoc.tsx +68 -68
- package/src/lib/ui/ScrollArea.tsx +45 -45
- package/src/lib/ui/Secret.tsx +133 -133
- package/src/lib/ui/Select.tsx +184 -184
- package/src/lib/ui/Separator.tsx +25 -25
- package/src/lib/ui/Skeleton.tsx +15 -15
- package/src/lib/ui/Slider.tsx +25 -25
- package/src/lib/ui/Stepper.tsx +8 -8
- package/src/lib/ui/Switch.tsx +26 -26
- package/src/lib/ui/SyntaxHighlight.tsx +54 -54
- package/src/lib/ui/Tabs.tsx +52 -52
- package/src/lib/ui/Textarea.tsx +22 -22
- package/src/lib/ui/Toggle.tsx +42 -42
- package/src/lib/ui/ToggleGroup.tsx +58 -58
- package/src/lib/ui/Tooltip.tsx +65 -65
- package/src/lib/ui/Value.tsx +42 -42
- package/src/lib/ui/util.tsx +8 -8
- package/src/lib/util/MdxComponents.tsx +150 -150
- package/src/lib/util/cn.ts +6 -6
- package/src/lib/util/createVariantComponent.tsx +61 -61
- package/src/lib/util/ensureArray.ts +3 -3
- package/src/lib/util/flattenAllOf.ts +72 -72
- package/src/lib/util/flattenAllOfProcessor.ts +74 -74
- package/src/lib/util/hastToJsx.tsx +67 -67
- package/src/lib/util/humanFileSize.ts +15 -15
- package/src/lib/util/invariant.ts +52 -52
- package/src/lib/util/joinUrl.ts +60 -60
- package/src/lib/util/logInit.ts +9 -9
- package/src/lib/util/objectEntries.ts +5 -5
- package/src/lib/util/os.ts +18 -18
- package/src/lib/util/pastellize.ts +25 -25
- package/src/lib/util/problemJson.ts +63 -63
- package/src/lib/util/readFrontmatter.ts +14 -14
- package/src/lib/util/renderIf.ts +4 -4
- package/src/lib/util/scrollIntoViewIfNeeded.ts +18 -18
- package/src/lib/util/slugify.ts +21 -21
- package/src/lib/util/syncZustandState.ts +22 -22
- package/src/lib/util/traverse.ts +70 -70
- package/src/lib/util/types.ts +7 -7
- package/src/lib/util/url.ts +18 -18
- package/src/lib/util/useCopyToClipboard.ts +17 -17
- package/src/lib/util/useExposedProps.tsx +27 -27
- package/src/lib/util/useIsomorphicLayoutEffect.ts +6 -6
- package/src/lib/util/useLatest.ts +18 -18
- package/src/lib/util/useOnScreen.ts +34 -34
- package/src/lib/util/useScrollToAnchor.ts +75 -75
- package/src/lib/util/useScrollToTop.ts +18 -18
- package/src/types.d.ts +66 -66
- package/src/vite/api/SchemaManager.ts +371 -371
- package/src/vite/api/schema-codegen.ts +163 -163
- package/src/vite/build.ts +237 -237
- package/src/vite/config.ts +273 -273
- package/src/vite/css/collect.ts +42 -42
- package/src/vite/css/plugin.ts +105 -105
- package/src/vite/debug.ts +18 -18
- package/src/vite/dev-portal-env.ts +29 -29
- package/src/vite/dev-server.ts +294 -294
- package/src/vite/html.ts +52 -52
- package/src/vite/index.ts +2 -2
- package/src/vite/llms.ts +99 -99
- package/src/vite/mdx/rehype-extract-toc-with-jsx-export.ts +19 -19
- package/src/vite/mdx/rehype-extract-toc-with-jsx.ts +70 -70
- package/src/vite/mdx/remark-code-tabs.ts +47 -47
- package/src/vite/mdx/remark-inject-filepath.ts +13 -13
- package/src/vite/mdx/remark-last-modified.ts +103 -103
- package/src/vite/mdx/remark-link-rewrite.ts +23 -23
- package/src/vite/mdx/remark-normalize-image-url.ts +15 -15
- package/src/vite/mdx/remark-static-generation.ts +149 -149
- package/src/vite/mdx/utils.ts +32 -32
- package/src/vite/output.ts +261 -261
- package/src/vite/package-root.ts +12 -12
- package/src/vite/pagefind-dev-index.ts +100 -100
- package/src/vite/pagefind-dev-stub.ts +17 -17
- package/src/vite/plugin-api-keys.ts +55 -55
- package/src/vite/plugin-api.ts +328 -328
- package/src/vite/plugin-auth.ts +41 -41
- package/src/vite/plugin-component.ts +45 -45
- package/src/vite/plugin-config-reload.ts +47 -47
- package/src/vite/plugin-config.ts +131 -131
- package/src/vite/plugin-custom-pages.ts +36 -36
- package/src/vite/plugin-doc-metadata.ts +45 -45
- package/src/vite/plugin-docs.ts +205 -205
- package/src/vite/plugin-local-manifest.ts +100 -100
- package/src/vite/plugin-markdown-export.ts +214 -214
- package/src/vite/plugin-mdx.ts +149 -149
- package/src/vite/plugin-metadata.ts +30 -30
- package/src/vite/plugin-modules.ts +188 -188
- package/src/vite/plugin-navigation.ts +106 -106
- package/src/vite/plugin-offroot-resolve.ts +67 -67
- package/src/vite/plugin-search.ts +59 -59
- package/src/vite/plugin-shiki-register.ts +97 -97
- package/src/vite/plugin-theme.ts +458 -458
- package/src/vite/plugin.ts +48 -48
- package/src/vite/prerender/prerender.ts +242 -242
- package/src/vite/prerender/utils.ts +76 -76
- package/src/vite/prerender/worker.ts +118 -118
- package/src/vite/reporter.ts +34 -34
- package/src/vite/shadcn-registry.ts +48 -48
- package/src/vite/sitemap.ts +78 -78
- package/src/vite/ssr-templates/cloudflare.ts +19 -19
- package/src/vite/ssr-templates/node.ts +25 -25
- package/src/vite/ssr-templates/vercel.ts +22 -22
- package/src/vite/watch-ignore.ts +70 -70
- package/src/vite/zuplo.ts +30 -30
- package/src/vite-env.d.ts +6 -6
- package/src/zuplo/enrich-with-zuplo-mcp.ts +168 -168
- package/src/zuplo/enrich-with-zuplo.ts +256 -256
- package/src/zuplo/policy-types.ts +46 -46
- package/src/zuplo/with-zuplo-processors.ts +36 -36
- package/src/zuplo/with-zuplo.ts +14 -14
- package/dist/declarations/cli/platform/client/deploy-server.d.ts +0 -16
|
@@ -1,663 +1,663 @@
|
|
|
1
|
-
import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { ApitogoError } from "../../lib/util/invariant.js";
|
|
4
|
-
import { docsFromPaths, starterPage } from "./init.js";
|
|
5
|
-
import { detectExistingDocs } from "./layout.js";
|
|
6
|
-
import { loadConfigV2 } from "./load.js";
|
|
7
|
-
import { parseConfigV2 } from "./parse.js";
|
|
8
|
-
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
9
|
-
import { checkDependencies } from "./rules.js";
|
|
10
|
-
import type { ConfigV2 } from "./schema.js";
|
|
11
|
-
|
|
12
|
-
export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
|
|
13
|
-
export type ServiceName = (typeof SERVICES)[number];
|
|
14
|
-
|
|
15
|
-
export class ServiceEditError extends ApitogoError {
|
|
16
|
-
constructor(message: string, developerHint?: string) {
|
|
17
|
-
super(message, { developerHint, title: "Cannot change the services" });
|
|
18
|
-
this.name = "ServiceEditError";
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function isServiceName(value: string): value is ServiceName {
|
|
23
|
-
return (SERVICES as readonly string[]).includes(value);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** Options for `apitogo add`, one group per service (Reference, section 1.3). Everything is optional here. */
|
|
27
|
-
export type AddOptions = {
|
|
28
|
-
dir: string;
|
|
29
|
-
/** Add the section with `enabled: false`. */
|
|
30
|
-
disabled?: boolean;
|
|
31
|
-
/** Include every docs folder found instead of asking. */
|
|
32
|
-
acceptAllDocs?: boolean;
|
|
33
|
-
portal?: {
|
|
34
|
-
name?: string;
|
|
35
|
-
docs?: string[] | false;
|
|
36
|
-
docsProvider?: "apitogo" | "external";
|
|
37
|
-
docsUrl?: string;
|
|
38
|
-
noConsole?: boolean;
|
|
39
|
-
requireAuth?: boolean;
|
|
40
|
-
search?: "pagefind" | "inkeep" | "none";
|
|
41
|
-
};
|
|
42
|
-
gateway?: {
|
|
43
|
-
provider?: string;
|
|
44
|
-
origin?: "managed" | "proxy";
|
|
45
|
-
url?: string;
|
|
46
|
-
source?: string;
|
|
47
|
-
runtime?: "nodejs" | "python" | "dotnet";
|
|
48
|
-
runtimeVersion?: string;
|
|
49
|
-
exclude?: string[];
|
|
50
|
-
spec?: string[];
|
|
51
|
-
createSpec?: boolean;
|
|
52
|
-
auth?: "identity" | "oidc";
|
|
53
|
-
oidcMetadataUrl?: string;
|
|
54
|
-
issuers?: string[];
|
|
55
|
-
audiences?: string[];
|
|
56
|
-
rateLimit?: string;
|
|
57
|
-
quota?: string;
|
|
58
|
-
cors?: string[];
|
|
59
|
-
};
|
|
60
|
-
identity?: {
|
|
61
|
-
native?: boolean;
|
|
62
|
-
provider?: "apitogo" | "oidc";
|
|
63
|
-
issuer?: string;
|
|
64
|
-
clientId?: string;
|
|
65
|
-
connection?: string;
|
|
66
|
-
authority?: string;
|
|
67
|
-
connectionClientId?: string;
|
|
68
|
-
};
|
|
69
|
-
plans?: {
|
|
70
|
-
file?: string;
|
|
71
|
-
template?: "free" | "tiered" | "none";
|
|
72
|
-
billing?: "stripe" | "none";
|
|
73
|
-
};
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
export type PlannedFile = { path: string; content: string };
|
|
77
|
-
|
|
78
|
-
export type ServiceEdit = {
|
|
79
|
-
service: ServiceName;
|
|
80
|
-
action: "add" | "remove";
|
|
81
|
-
dir: string;
|
|
82
|
-
configPath: string;
|
|
83
|
-
/** The config that will be written. */
|
|
84
|
-
config: ConfigV2;
|
|
85
|
-
/** Files created by an add; files that already exist are listed in `keep` and never overwritten. */
|
|
86
|
-
files: PlannedFile[];
|
|
87
|
-
keep: string[];
|
|
88
|
-
/** Human-readable follow-ups: secrets to set, files left behind after a remove. */
|
|
89
|
-
notes: string[];
|
|
90
|
-
};
|
|
91
|
-
|
|
92
|
-
const exists = (target: string) =>
|
|
93
|
-
access(target).then(
|
|
94
|
-
() => true,
|
|
95
|
-
() => false,
|
|
96
|
-
);
|
|
97
|
-
|
|
98
|
-
const rel = (dir: string, target: string) =>
|
|
99
|
-
path.relative(dir, target).split(path.sep).join("/") || ".";
|
|
100
|
-
|
|
101
|
-
const STARTER_SPEC = (name: string) => `openapi: 3.1.0
|
|
102
|
-
info:
|
|
103
|
-
title: ${JSON.stringify(name)}
|
|
104
|
-
version: 1.0.0
|
|
105
|
-
paths:
|
|
106
|
-
/health:
|
|
107
|
-
get:
|
|
108
|
-
operationId: getHealth
|
|
109
|
-
summary: Health check
|
|
110
|
-
responses:
|
|
111
|
-
"200":
|
|
112
|
-
description: The service is up.
|
|
113
|
-
`;
|
|
114
|
-
|
|
115
|
-
const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
|
|
116
|
-
none: [],
|
|
117
|
-
free: [
|
|
118
|
-
{
|
|
119
|
-
sku: "free",
|
|
120
|
-
displayName: "Free",
|
|
121
|
-
default: true,
|
|
122
|
-
limits: {
|
|
123
|
-
// The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
|
|
124
|
-
rateLimit: { calls: 3, periodSeconds: 60 },
|
|
125
|
-
quota: { calls: 1000, periodDays: 30 },
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
],
|
|
129
|
-
tiered: [
|
|
130
|
-
{
|
|
131
|
-
sku: "free",
|
|
132
|
-
displayName: "Free",
|
|
133
|
-
default: true,
|
|
134
|
-
limits: {
|
|
135
|
-
// The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
|
|
136
|
-
rateLimit: { calls: 3, periodSeconds: 60 },
|
|
137
|
-
quota: { calls: 1000, periodDays: 30 },
|
|
138
|
-
},
|
|
139
|
-
},
|
|
140
|
-
{
|
|
141
|
-
sku: "pro",
|
|
142
|
-
displayName: "Pro",
|
|
143
|
-
priceAmount: 49,
|
|
144
|
-
priceCurrency: "usd",
|
|
145
|
-
billingInterval: "month",
|
|
146
|
-
limits: {
|
|
147
|
-
rateLimit: { calls: 100, periodSeconds: 60 },
|
|
148
|
-
quota: { calls: 100000, periodDays: 30 },
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
{
|
|
152
|
-
sku: "enterprise",
|
|
153
|
-
displayName: "Enterprise",
|
|
154
|
-
pricingModel: "custom",
|
|
155
|
-
priceLabel: "Contact us",
|
|
156
|
-
},
|
|
157
|
-
],
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
function pair(value: string, flag: string, unit: string): [number, number] {
|
|
161
|
-
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
162
|
-
if (!match) {
|
|
163
|
-
throw new ServiceEditError(
|
|
164
|
-
`${flag} must look like <calls>/<${unit}>, for example 100/60.`,
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
return [Number(match[1]), Number(match[2])];
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** The path of a file inside the project, relative to the config, refusing anything outside it. */
|
|
171
|
-
function insideProject(dir: string, requested: string, what: string): string {
|
|
172
|
-
const absolute = path.resolve(dir, requested);
|
|
173
|
-
const relative = path.relative(dir, absolute);
|
|
174
|
-
if (
|
|
175
|
-
relative === "" ||
|
|
176
|
-
relative.startsWith("..") ||
|
|
177
|
-
path.isAbsolute(relative)
|
|
178
|
-
) {
|
|
179
|
-
throw new ServiceEditError(
|
|
180
|
-
`${what} "${requested}" is outside the project directory.`,
|
|
181
|
-
);
|
|
182
|
-
}
|
|
183
|
-
return relative.split(path.sep).join("/");
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
type Context = {
|
|
187
|
-
dir: string;
|
|
188
|
-
config: ConfigV2;
|
|
189
|
-
resolved: ResolvedConfigV2;
|
|
190
|
-
options: AddOptions;
|
|
191
|
-
files: PlannedFile[];
|
|
192
|
-
keep: string[];
|
|
193
|
-
notes: string[];
|
|
194
|
-
};
|
|
195
|
-
|
|
196
|
-
async function addFile(context: Context, target: string, content: string) {
|
|
197
|
-
if (await exists(target)) context.keep.push(target);
|
|
198
|
-
else context.files.push({ path: target, content });
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function buildPortal(
|
|
202
|
-
context: Context,
|
|
203
|
-
): Promise<NonNullable<ConfigV2["portal"]>> {
|
|
204
|
-
const { options, dir, resolved } = context;
|
|
205
|
-
const o = options.portal ?? {};
|
|
206
|
-
const external = o.docsProvider === "external";
|
|
207
|
-
if (!external && o.docsUrl) {
|
|
208
|
-
throw new ServiceEditError("--docs-url needs --docs-provider external.");
|
|
209
|
-
}
|
|
210
|
-
if (external && !o.docsUrl) {
|
|
211
|
-
throw new ServiceEditError(
|
|
212
|
-
"--docs-provider external needs --docs-url <https url>.",
|
|
213
|
-
);
|
|
214
|
-
}
|
|
215
|
-
const name = o.name?.trim() || resolved.project.name;
|
|
216
|
-
|
|
217
|
-
let content: string[] = [];
|
|
218
|
-
if (!external) {
|
|
219
|
-
if (Array.isArray(o.docs) && o.docs.length > 0) {
|
|
220
|
-
content = await docsFromPaths(dir, o.docs);
|
|
221
|
-
} else if (o.docs !== false) {
|
|
222
|
-
const candidates = await detectExistingDocs(dir);
|
|
223
|
-
if (
|
|
224
|
-
candidates.length === 1 ||
|
|
225
|
-
(candidates.length > 1 && options.acceptAllDocs)
|
|
226
|
-
) {
|
|
227
|
-
content = candidates.map((candidate) => candidate.glob);
|
|
228
|
-
} else if (candidates.length > 1) {
|
|
229
|
-
throw new ServiceEditError(
|
|
230
|
-
`Several places with documentation were found (${candidates.map((c) => c.path).join(", ")}).`,
|
|
231
|
-
"Pass --docs <path> for each one to include, --no-docs to include none, or --yes to include all.",
|
|
232
|
-
);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (content.length === 0) {
|
|
236
|
-
await addFile(
|
|
237
|
-
context,
|
|
238
|
-
path.join(resolved.root, "portal", "pages", "index.mdx"),
|
|
239
|
-
starterPage(name),
|
|
240
|
-
);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
...(options.disabled ? { enabled: false } : {}),
|
|
246
|
-
name,
|
|
247
|
-
...(external
|
|
248
|
-
? { docs: { provider: "external" as const, url: o.docsUrl } }
|
|
249
|
-
: content.length > 0
|
|
250
|
-
? { docs: { content } }
|
|
251
|
-
: {}),
|
|
252
|
-
...(o.noConsole ? { console: { enabled: false } } : {}),
|
|
253
|
-
...(o.requireAuth ? { requireAuth: true } : {}),
|
|
254
|
-
...(o.search && !external ? { search: o.search } : {}),
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
async function buildGateway(
|
|
259
|
-
context: Context,
|
|
260
|
-
): Promise<NonNullable<ConfigV2["gateway"]>> {
|
|
261
|
-
const { options, dir, resolved } = context;
|
|
262
|
-
const o = options.gateway ?? {};
|
|
263
|
-
const mode =
|
|
264
|
-
o.origin ??
|
|
265
|
-
(o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
|
|
266
|
-
if (!mode) {
|
|
267
|
-
throw new ServiceEditError(
|
|
268
|
-
"The gateway needs an origin: say how it reaches your API.",
|
|
269
|
-
"Pass --origin proxy --url <https url> for an API you host, or --origin managed --source <folder> --runtime <nodejs|python|dotnet> --runtime-version <version> to have it run your code.",
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
let origin: NonNullable<ConfigV2["gateway"]>["origin"];
|
|
273
|
-
if (mode === "proxy") {
|
|
274
|
-
if (!o.url)
|
|
275
|
-
throw new ServiceEditError("--origin proxy needs --url <https url>.");
|
|
276
|
-
origin = { mode, url: o.url };
|
|
277
|
-
} else {
|
|
278
|
-
if (!o.source || !o.runtime || !o.runtimeVersion) {
|
|
279
|
-
throw new ServiceEditError(
|
|
280
|
-
"--origin managed needs --source, --runtime and --runtime-version.",
|
|
281
|
-
"For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
|
|
282
|
-
);
|
|
283
|
-
}
|
|
284
|
-
origin = {
|
|
285
|
-
mode,
|
|
286
|
-
source: insideProject(dir, o.source, "Source folder"),
|
|
287
|
-
runtime: o.runtime,
|
|
288
|
-
runtimeVersion: o.runtimeVersion,
|
|
289
|
-
...(o.exclude?.length ? { exclude: o.exclude } : {}),
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
let specs: string[];
|
|
294
|
-
if (o.spec?.length) {
|
|
295
|
-
specs = [];
|
|
296
|
-
for (const requested of o.spec) {
|
|
297
|
-
const relative = insideProject(dir, requested, "OpenAPI file");
|
|
298
|
-
if (
|
|
299
|
-
!(await stat(path.join(dir, relative)).then(
|
|
300
|
-
(s) => s.isFile(),
|
|
301
|
-
() => false,
|
|
302
|
-
))
|
|
303
|
-
) {
|
|
304
|
-
throw new ServiceEditError(
|
|
305
|
-
`OpenAPI file "${requested}" does not exist in ${dir}.`,
|
|
306
|
-
);
|
|
307
|
-
}
|
|
308
|
-
specs.push(relative);
|
|
309
|
-
}
|
|
310
|
-
} else if (o.createSpec) {
|
|
311
|
-
const target = path.join(resolved.apisDir, "openapi.yaml");
|
|
312
|
-
await addFile(context, target, STARTER_SPEC(resolved.project.name));
|
|
313
|
-
specs = [rel(dir, target)];
|
|
314
|
-
} else {
|
|
315
|
-
throw new ServiceEditError(
|
|
316
|
-
"The gateway needs an OpenAPI file and none was given.",
|
|
317
|
-
"Pass --spec <path> for an existing file, or --create-spec to start from a small one.",
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const auth = o.auth
|
|
322
|
-
? o.auth === "identity"
|
|
323
|
-
? { mode: "identity" as const }
|
|
324
|
-
: {
|
|
325
|
-
mode: "oidc" as const,
|
|
326
|
-
...(o.oidcMetadataUrl ? { metadataUrl: o.oidcMetadataUrl } : {}),
|
|
327
|
-
...(o.issuers?.length ? { issuers: o.issuers } : {}),
|
|
328
|
-
...(o.audiences?.length ? { audiences: o.audiences } : {}),
|
|
329
|
-
}
|
|
330
|
-
: undefined;
|
|
331
|
-
|
|
332
|
-
const rateLimit = o.rateLimit
|
|
333
|
-
? pair(o.rateLimit, "--rate-limit", "seconds")
|
|
334
|
-
: undefined;
|
|
335
|
-
const quota = o.quota ? pair(o.quota, "--quota", "days") : undefined;
|
|
336
|
-
const policies = {
|
|
337
|
-
...(rateLimit
|
|
338
|
-
? { rateLimit: { calls: rateLimit[0], periodSeconds: rateLimit[1] } }
|
|
339
|
-
: {}),
|
|
340
|
-
...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
|
|
341
|
-
...(o.cors?.length ? { cors: o.cors } : {}),
|
|
342
|
-
};
|
|
343
|
-
|
|
344
|
-
return {
|
|
345
|
-
...(options.disabled ? { enabled: false } : {}),
|
|
346
|
-
...(o.provider ? { provider: o.provider } : {}),
|
|
347
|
-
origin,
|
|
348
|
-
specs,
|
|
349
|
-
...(auth ? { auth } : {}),
|
|
350
|
-
...(Object.keys(policies).length > 0 ? { policies } : {}),
|
|
351
|
-
};
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
function buildIdentity(context: Context): NonNullable<ConfigV2["identity"]> {
|
|
355
|
-
const { options } = context;
|
|
356
|
-
const o = options.identity ?? {};
|
|
357
|
-
const provider = o.provider ?? "apitogo";
|
|
358
|
-
if (provider === "oidc") {
|
|
359
|
-
if (!o.issuer)
|
|
360
|
-
throw new ServiceEditError("--provider oidc needs --issuer <https url>.");
|
|
361
|
-
if (o.connection || o.native === false) {
|
|
362
|
-
throw new ServiceEditError(
|
|
363
|
-
"--no-native and --connection only apply to the apitogo identity provider.",
|
|
364
|
-
);
|
|
365
|
-
}
|
|
366
|
-
return {
|
|
367
|
-
...(options.disabled ? { enabled: false } : {}),
|
|
368
|
-
provider,
|
|
369
|
-
issuer: o.issuer,
|
|
370
|
-
...(o.clientId ? { clientId: o.clientId } : {}),
|
|
371
|
-
};
|
|
372
|
-
}
|
|
373
|
-
if (o.issuer || o.clientId) {
|
|
374
|
-
throw new ServiceEditError(
|
|
375
|
-
"--issuer and --client-id need --provider oidc.",
|
|
376
|
-
);
|
|
377
|
-
}
|
|
378
|
-
const connections = o.connection
|
|
379
|
-
? (() => {
|
|
380
|
-
if (!o.authority || !o.connectionClientId) {
|
|
381
|
-
throw new ServiceEditError(
|
|
382
|
-
"--connection needs --authority <https url> and --connection-client-id <id>.",
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
const secret = `${o.connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_CLIENT_SECRET`;
|
|
386
|
-
context.notes.push(
|
|
387
|
-
`Set the connection's client secret: apitogo secrets set ${secret}`,
|
|
388
|
-
);
|
|
389
|
-
return [
|
|
390
|
-
{
|
|
391
|
-
id: o.connection,
|
|
392
|
-
type: "oidc" as const,
|
|
393
|
-
authority: o.authority,
|
|
394
|
-
clientId: o.connectionClientId,
|
|
395
|
-
clientSecret: `secret:${secret}`,
|
|
396
|
-
},
|
|
397
|
-
];
|
|
398
|
-
})()
|
|
399
|
-
: undefined;
|
|
400
|
-
return {
|
|
401
|
-
...(options.disabled ? { enabled: false } : {}),
|
|
402
|
-
...(provider !== "apitogo" ? { provider } : {}),
|
|
403
|
-
...(o.native === false ? { native: false } : {}),
|
|
404
|
-
...(connections ? { connections } : {}),
|
|
405
|
-
};
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
async function buildPlans(
|
|
409
|
-
context: Context,
|
|
410
|
-
): Promise<NonNullable<ConfigV2["plans"]>> {
|
|
411
|
-
const { options, dir, resolved } = context;
|
|
412
|
-
const o = options.plans ?? {};
|
|
413
|
-
const template = o.template ?? "free";
|
|
414
|
-
const billing = o.billing ?? "stripe";
|
|
415
|
-
if (template === "tiered" && billing === "none") {
|
|
416
|
-
throw new ServiceEditError(
|
|
417
|
-
"The tiered template has paid plans, which need a billing provider.",
|
|
418
|
-
"Use --billing stripe, or --template free.",
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
const file = o.file ? insideProject(dir, o.file, "Plans file") : undefined;
|
|
422
|
-
const target = file
|
|
423
|
-
? path.join(dir, file)
|
|
424
|
-
: (resolved.plans?.file ?? path.join(resolved.root, "plans.json"));
|
|
425
|
-
await addFile(
|
|
426
|
-
context,
|
|
427
|
-
target,
|
|
428
|
-
`${JSON.stringify({ version: 1, plans: PLAN_TEMPLATES[template] }, null, 2)}\n`,
|
|
429
|
-
);
|
|
430
|
-
if (billing === "stripe") {
|
|
431
|
-
context.notes.push(
|
|
432
|
-
"Set the billing key: apitogo secrets set STRIPE_API_KEY",
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
return {
|
|
436
|
-
...(options.disabled ? { enabled: false } : {}),
|
|
437
|
-
...(file ? { file } : {}),
|
|
438
|
-
...(billing === "stripe"
|
|
439
|
-
? {
|
|
440
|
-
billing: {
|
|
441
|
-
provider: "stripe" as const,
|
|
442
|
-
apiKey: "secret:STRIPE_API_KEY",
|
|
443
|
-
},
|
|
444
|
-
}
|
|
445
|
-
: { billing: { provider: "none" as const } }),
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
function reparse(configPath: string, raw: ConfigV2): ConfigV2 {
|
|
450
|
-
// The strict parser is the one source of truth for what a valid config is.
|
|
451
|
-
return parseConfigV2(JSON.parse(JSON.stringify(raw)), configPath);
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
function dependencyProblems(config: ConfigV2, configPath: string) {
|
|
455
|
-
return checkDependencies(config, resolveConfigV2(config, configPath));
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/**
|
|
459
|
-
* Decides what `add <service>` would write without touching the disk. The strict schema and the dependency rules
|
|
460
|
-
* (Reference, section 2.1) run on the new config first, so a refused add changes nothing.
|
|
461
|
-
*/
|
|
462
|
-
export async function planAddService(
|
|
463
|
-
service: ServiceName,
|
|
464
|
-
options: AddOptions,
|
|
465
|
-
): Promise<ServiceEdit> {
|
|
466
|
-
const dir = path.resolve(options.dir);
|
|
467
|
-
const { config, resolved } = loadConfigV2(dir);
|
|
468
|
-
if (config[service] !== undefined) {
|
|
469
|
-
throw new ServiceEditError(
|
|
470
|
-
`${service} is already in ${path.basename(resolved.configPath)}.`,
|
|
471
|
-
`Edit the file, or run \`apitogo remove ${service}\` first. Its files are kept.`,
|
|
472
|
-
);
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
const context: Context = {
|
|
476
|
-
dir,
|
|
477
|
-
config,
|
|
478
|
-
resolved,
|
|
479
|
-
options,
|
|
480
|
-
files: [],
|
|
481
|
-
keep: [],
|
|
482
|
-
notes: [],
|
|
483
|
-
};
|
|
484
|
-
const section =
|
|
485
|
-
service === "portal"
|
|
486
|
-
? await buildPortal(context)
|
|
487
|
-
: service === "gateway"
|
|
488
|
-
? await buildGateway(context)
|
|
489
|
-
: service === "identity"
|
|
490
|
-
? buildIdentity(context)
|
|
491
|
-
: await buildPlans(context);
|
|
492
|
-
|
|
493
|
-
const next = reparse(resolved.configPath, { ...config, [service]: section });
|
|
494
|
-
if (!options.disabled) {
|
|
495
|
-
const before = new Set(
|
|
496
|
-
dependencyProblems(config, resolved.configPath).map(
|
|
497
|
-
(i) => i.code + i.path,
|
|
498
|
-
),
|
|
499
|
-
);
|
|
500
|
-
const added = dependencyProblems(next, resolved.configPath).filter(
|
|
501
|
-
(issue) => !before.has(issue.code + issue.path),
|
|
502
|
-
);
|
|
503
|
-
if (added.length > 0) {
|
|
504
|
-
throw new ServiceEditError(
|
|
505
|
-
added.map((issue) => issue.message).join(" "),
|
|
506
|
-
added
|
|
507
|
-
.map((issue) => issue.hint)
|
|
508
|
-
.filter(Boolean)
|
|
509
|
-
.join(" ") || "Add the missing service first.",
|
|
510
|
-
);
|
|
511
|
-
}
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
return {
|
|
515
|
-
service,
|
|
516
|
-
action: "add",
|
|
517
|
-
dir,
|
|
518
|
-
configPath: resolved.configPath,
|
|
519
|
-
config: next,
|
|
520
|
-
files: context.files,
|
|
521
|
-
keep: context.keep,
|
|
522
|
-
notes: context.notes,
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/**
|
|
527
|
-
* Decides what `remove <service>` would do: the section goes, every file stays. Refuses when another enabled
|
|
528
|
-
* service would be left without something it needs.
|
|
529
|
-
*/
|
|
530
|
-
export async function planRemoveService(
|
|
531
|
-
service: ServiceName,
|
|
532
|
-
options: { dir: string },
|
|
533
|
-
): Promise<ServiceEdit> {
|
|
534
|
-
const dir = path.resolve(options.dir);
|
|
535
|
-
const { config, resolved } = loadConfigV2(dir);
|
|
536
|
-
if (config[service] === undefined) {
|
|
537
|
-
throw new ServiceEditError(
|
|
538
|
-
`${service} is not in ${path.basename(resolved.configPath)}.`,
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
const { [service]: removed, ...rest } = config;
|
|
542
|
-
const next = reparse(resolved.configPath, rest as ConfigV2);
|
|
543
|
-
|
|
544
|
-
const before = new Set(
|
|
545
|
-
dependencyProblems(config, resolved.configPath).map((i) => i.code + i.path),
|
|
546
|
-
);
|
|
547
|
-
const caused = dependencyProblems(next, resolved.configPath).filter(
|
|
548
|
-
(issue) => !before.has(issue.code + issue.path),
|
|
549
|
-
);
|
|
550
|
-
if (caused.length > 0) {
|
|
551
|
-
throw new ServiceEditError(
|
|
552
|
-
`${service} cannot be removed while something depends on it: ${caused.map((issue) => issue.message).join(" ")}`,
|
|
553
|
-
"Remove or change the dependent service first.",
|
|
554
|
-
);
|
|
555
|
-
}
|
|
556
|
-
|
|
557
|
-
const left: string[] = [];
|
|
558
|
-
const r = resolved;
|
|
559
|
-
if (service === "portal" && r.portal) left.push(rel(dir, r.portal.dir));
|
|
560
|
-
if (service === "plans" && r.plans) left.push(rel(dir, r.plans.file));
|
|
561
|
-
if (service === "gateway" && r.gateway)
|
|
562
|
-
left.push(...r.gateway.specs.map((s) => rel(dir, s)));
|
|
563
|
-
void removed;
|
|
564
|
-
|
|
565
|
-
return {
|
|
566
|
-
service,
|
|
567
|
-
action: "remove",
|
|
568
|
-
dir,
|
|
569
|
-
configPath: resolved.configPath,
|
|
570
|
-
config: next,
|
|
571
|
-
files: [],
|
|
572
|
-
keep: [],
|
|
573
|
-
notes: left.length > 0 ? [`Files are kept: ${left.join(", ")}.`] : [],
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
/** Replaces the config file atomically: a failure leaves the old file in place. */
|
|
578
|
-
async function writeConfig(
|
|
579
|
-
configPath: string,
|
|
580
|
-
config: ConfigV2,
|
|
581
|
-
): Promise<void> {
|
|
582
|
-
const temporary = `${configPath}.${process.pid}.tmp`;
|
|
583
|
-
try {
|
|
584
|
-
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
585
|
-
await rename(temporary, configPath);
|
|
586
|
-
} catch (error) {
|
|
587
|
-
await rm(temporary, { force: true });
|
|
588
|
-
throw error;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
/** Writes new files (never over existing ones), then replaces the config atomically. A failure leaves the old config. */
|
|
593
|
-
export async function applyServiceEdit(
|
|
594
|
-
edit: ServiceEdit,
|
|
595
|
-
): Promise<{ created: string[] }> {
|
|
596
|
-
const created: string[] = [];
|
|
597
|
-
for (const file of edit.files) {
|
|
598
|
-
await mkdir(path.dirname(file.path), { recursive: true });
|
|
599
|
-
try {
|
|
600
|
-
await writeFile(file.path, file.content, { flag: "wx" });
|
|
601
|
-
created.push(file.path);
|
|
602
|
-
} catch (error) {
|
|
603
|
-
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
await writeConfig(edit.configPath, edit.config);
|
|
607
|
-
return { created };
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
/** The "will change" list shown before anything is written. */
|
|
611
|
-
export function formatServiceEdit(edit: ServiceEdit): string[] {
|
|
612
|
-
const verb = edit.action === "add" ? "Adds" : "Removes";
|
|
613
|
-
return [
|
|
614
|
-
`${verb} the ${edit.service} section in ${path.basename(edit.configPath)}.`,
|
|
615
|
-
...(edit.files.length > 0
|
|
616
|
-
? [
|
|
617
|
-
"Will create:",
|
|
618
|
-
...edit.files.map((file) => ` ${rel(edit.dir, file.path)}`),
|
|
619
|
-
]
|
|
620
|
-
: []),
|
|
621
|
-
...(edit.keep.length > 0
|
|
622
|
-
? [
|
|
623
|
-
`Already there, left alone: ${edit.keep.map((file) => rel(edit.dir, file)).join(", ")}`,
|
|
624
|
-
]
|
|
625
|
-
: []),
|
|
626
|
-
...edit.notes,
|
|
627
|
-
];
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Writes `project.id` into the config when it has none: `deploy` and `project create` link a folder to a platform
|
|
632
|
-
* project (Reference, section 3.1). An id that is already there is never replaced.
|
|
633
|
-
*/
|
|
634
|
-
export async function setProjectId(
|
|
635
|
-
dir: string,
|
|
636
|
-
projectId: string,
|
|
637
|
-
): Promise<boolean> {
|
|
638
|
-
const { config, resolved } = loadConfigV2(dir);
|
|
639
|
-
if (config.project?.id) return false;
|
|
640
|
-
const next = reparse(resolved.configPath, {
|
|
641
|
-
...config,
|
|
642
|
-
project: { ...config.project, id: projectId },
|
|
643
|
-
});
|
|
644
|
-
await writeConfig(resolved.configPath, next);
|
|
645
|
-
return true;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
|
|
649
|
-
export async function clearProjectId(
|
|
650
|
-
dir: string,
|
|
651
|
-
projectId: string,
|
|
652
|
-
): Promise<boolean> {
|
|
653
|
-
const { config, resolved } = loadConfigV2(dir);
|
|
654
|
-
if (config.project?.id !== projectId) return false;
|
|
655
|
-
const { id: _id, ...project } = config.project;
|
|
656
|
-
const { project: _old, ...rest } = config;
|
|
657
|
-
const next = reparse(resolved.configPath, {
|
|
658
|
-
...rest,
|
|
659
|
-
...(Object.keys(project).length > 0 ? { project } : {}),
|
|
660
|
-
});
|
|
661
|
-
await writeConfig(resolved.configPath, next);
|
|
662
|
-
return true;
|
|
663
|
-
}
|
|
1
|
+
import { access, mkdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ApitogoError } from "../../lib/util/invariant.js";
|
|
4
|
+
import { docsFromPaths, starterPage } from "./init.js";
|
|
5
|
+
import { detectExistingDocs } from "./layout.js";
|
|
6
|
+
import { loadConfigV2 } from "./load.js";
|
|
7
|
+
import { parseConfigV2 } from "./parse.js";
|
|
8
|
+
import { type ResolvedConfigV2, resolveConfigV2 } from "./resolve.js";
|
|
9
|
+
import { checkDependencies } from "./rules.js";
|
|
10
|
+
import type { ConfigV2 } from "./schema.js";
|
|
11
|
+
|
|
12
|
+
export const SERVICES = ["portal", "gateway", "identity", "plans"] as const;
|
|
13
|
+
export type ServiceName = (typeof SERVICES)[number];
|
|
14
|
+
|
|
15
|
+
export class ServiceEditError extends ApitogoError {
|
|
16
|
+
constructor(message: string, developerHint?: string) {
|
|
17
|
+
super(message, { developerHint, title: "Cannot change the services" });
|
|
18
|
+
this.name = "ServiceEditError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isServiceName(value: string): value is ServiceName {
|
|
23
|
+
return (SERVICES as readonly string[]).includes(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Options for `apitogo add`, one group per service (Reference, section 1.3). Everything is optional here. */
|
|
27
|
+
export type AddOptions = {
|
|
28
|
+
dir: string;
|
|
29
|
+
/** Add the section with `enabled: false`. */
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** Include every docs folder found instead of asking. */
|
|
32
|
+
acceptAllDocs?: boolean;
|
|
33
|
+
portal?: {
|
|
34
|
+
name?: string;
|
|
35
|
+
docs?: string[] | false;
|
|
36
|
+
docsProvider?: "apitogo" | "external";
|
|
37
|
+
docsUrl?: string;
|
|
38
|
+
noConsole?: boolean;
|
|
39
|
+
requireAuth?: boolean;
|
|
40
|
+
search?: "pagefind" | "inkeep" | "none";
|
|
41
|
+
};
|
|
42
|
+
gateway?: {
|
|
43
|
+
provider?: string;
|
|
44
|
+
origin?: "managed" | "proxy";
|
|
45
|
+
url?: string;
|
|
46
|
+
source?: string;
|
|
47
|
+
runtime?: "nodejs" | "python" | "dotnet";
|
|
48
|
+
runtimeVersion?: string;
|
|
49
|
+
exclude?: string[];
|
|
50
|
+
spec?: string[];
|
|
51
|
+
createSpec?: boolean;
|
|
52
|
+
auth?: "identity" | "oidc";
|
|
53
|
+
oidcMetadataUrl?: string;
|
|
54
|
+
issuers?: string[];
|
|
55
|
+
audiences?: string[];
|
|
56
|
+
rateLimit?: string;
|
|
57
|
+
quota?: string;
|
|
58
|
+
cors?: string[];
|
|
59
|
+
};
|
|
60
|
+
identity?: {
|
|
61
|
+
native?: boolean;
|
|
62
|
+
provider?: "apitogo" | "oidc";
|
|
63
|
+
issuer?: string;
|
|
64
|
+
clientId?: string;
|
|
65
|
+
connection?: string;
|
|
66
|
+
authority?: string;
|
|
67
|
+
connectionClientId?: string;
|
|
68
|
+
};
|
|
69
|
+
plans?: {
|
|
70
|
+
file?: string;
|
|
71
|
+
template?: "free" | "tiered" | "none";
|
|
72
|
+
billing?: "stripe" | "none";
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export type PlannedFile = { path: string; content: string };
|
|
77
|
+
|
|
78
|
+
export type ServiceEdit = {
|
|
79
|
+
service: ServiceName;
|
|
80
|
+
action: "add" | "remove";
|
|
81
|
+
dir: string;
|
|
82
|
+
configPath: string;
|
|
83
|
+
/** The config that will be written. */
|
|
84
|
+
config: ConfigV2;
|
|
85
|
+
/** Files created by an add; files that already exist are listed in `keep` and never overwritten. */
|
|
86
|
+
files: PlannedFile[];
|
|
87
|
+
keep: string[];
|
|
88
|
+
/** Human-readable follow-ups: secrets to set, files left behind after a remove. */
|
|
89
|
+
notes: string[];
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const exists = (target: string) =>
|
|
93
|
+
access(target).then(
|
|
94
|
+
() => true,
|
|
95
|
+
() => false,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const rel = (dir: string, target: string) =>
|
|
99
|
+
path.relative(dir, target).split(path.sep).join("/") || ".";
|
|
100
|
+
|
|
101
|
+
const STARTER_SPEC = (name: string) => `openapi: 3.1.0
|
|
102
|
+
info:
|
|
103
|
+
title: ${JSON.stringify(name)}
|
|
104
|
+
version: 1.0.0
|
|
105
|
+
paths:
|
|
106
|
+
/health:
|
|
107
|
+
get:
|
|
108
|
+
operationId: getHealth
|
|
109
|
+
summary: Health check
|
|
110
|
+
responses:
|
|
111
|
+
"200":
|
|
112
|
+
description: The service is up.
|
|
113
|
+
`;
|
|
114
|
+
|
|
115
|
+
const PLAN_TEMPLATES: Record<"free" | "tiered" | "none", unknown[]> = {
|
|
116
|
+
none: [],
|
|
117
|
+
free: [
|
|
118
|
+
{
|
|
119
|
+
sku: "free",
|
|
120
|
+
displayName: "Free",
|
|
121
|
+
default: true,
|
|
122
|
+
limits: {
|
|
123
|
+
// The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
|
|
124
|
+
rateLimit: { calls: 3, periodSeconds: 60 },
|
|
125
|
+
quota: { calls: 1000, periodDays: 30 },
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
tiered: [
|
|
130
|
+
{
|
|
131
|
+
sku: "free",
|
|
132
|
+
displayName: "Free",
|
|
133
|
+
default: true,
|
|
134
|
+
limits: {
|
|
135
|
+
// The platform's Free tier allows at most 3 calls per 60 seconds per plan; a higher number is refused.
|
|
136
|
+
rateLimit: { calls: 3, periodSeconds: 60 },
|
|
137
|
+
quota: { calls: 1000, periodDays: 30 },
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
sku: "pro",
|
|
142
|
+
displayName: "Pro",
|
|
143
|
+
priceAmount: 49,
|
|
144
|
+
priceCurrency: "usd",
|
|
145
|
+
billingInterval: "month",
|
|
146
|
+
limits: {
|
|
147
|
+
rateLimit: { calls: 100, periodSeconds: 60 },
|
|
148
|
+
quota: { calls: 100000, periodDays: 30 },
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
sku: "enterprise",
|
|
153
|
+
displayName: "Enterprise",
|
|
154
|
+
pricingModel: "custom",
|
|
155
|
+
priceLabel: "Contact us",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
function pair(value: string, flag: string, unit: string): [number, number] {
|
|
161
|
+
const match = /^(\d+)\/(\d+)$/.exec(value);
|
|
162
|
+
if (!match) {
|
|
163
|
+
throw new ServiceEditError(
|
|
164
|
+
`${flag} must look like <calls>/<${unit}>, for example 100/60.`,
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return [Number(match[1]), Number(match[2])];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The path of a file inside the project, relative to the config, refusing anything outside it. */
|
|
171
|
+
function insideProject(dir: string, requested: string, what: string): string {
|
|
172
|
+
const absolute = path.resolve(dir, requested);
|
|
173
|
+
const relative = path.relative(dir, absolute);
|
|
174
|
+
if (
|
|
175
|
+
relative === "" ||
|
|
176
|
+
relative.startsWith("..") ||
|
|
177
|
+
path.isAbsolute(relative)
|
|
178
|
+
) {
|
|
179
|
+
throw new ServiceEditError(
|
|
180
|
+
`${what} "${requested}" is outside the project directory.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
return relative.split(path.sep).join("/");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type Context = {
|
|
187
|
+
dir: string;
|
|
188
|
+
config: ConfigV2;
|
|
189
|
+
resolved: ResolvedConfigV2;
|
|
190
|
+
options: AddOptions;
|
|
191
|
+
files: PlannedFile[];
|
|
192
|
+
keep: string[];
|
|
193
|
+
notes: string[];
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
async function addFile(context: Context, target: string, content: string) {
|
|
197
|
+
if (await exists(target)) context.keep.push(target);
|
|
198
|
+
else context.files.push({ path: target, content });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function buildPortal(
|
|
202
|
+
context: Context,
|
|
203
|
+
): Promise<NonNullable<ConfigV2["portal"]>> {
|
|
204
|
+
const { options, dir, resolved } = context;
|
|
205
|
+
const o = options.portal ?? {};
|
|
206
|
+
const external = o.docsProvider === "external";
|
|
207
|
+
if (!external && o.docsUrl) {
|
|
208
|
+
throw new ServiceEditError("--docs-url needs --docs-provider external.");
|
|
209
|
+
}
|
|
210
|
+
if (external && !o.docsUrl) {
|
|
211
|
+
throw new ServiceEditError(
|
|
212
|
+
"--docs-provider external needs --docs-url <https url>.",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
const name = o.name?.trim() || resolved.project.name;
|
|
216
|
+
|
|
217
|
+
let content: string[] = [];
|
|
218
|
+
if (!external) {
|
|
219
|
+
if (Array.isArray(o.docs) && o.docs.length > 0) {
|
|
220
|
+
content = await docsFromPaths(dir, o.docs);
|
|
221
|
+
} else if (o.docs !== false) {
|
|
222
|
+
const candidates = await detectExistingDocs(dir);
|
|
223
|
+
if (
|
|
224
|
+
candidates.length === 1 ||
|
|
225
|
+
(candidates.length > 1 && options.acceptAllDocs)
|
|
226
|
+
) {
|
|
227
|
+
content = candidates.map((candidate) => candidate.glob);
|
|
228
|
+
} else if (candidates.length > 1) {
|
|
229
|
+
throw new ServiceEditError(
|
|
230
|
+
`Several places with documentation were found (${candidates.map((c) => c.path).join(", ")}).`,
|
|
231
|
+
"Pass --docs <path> for each one to include, --no-docs to include none, or --yes to include all.",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (content.length === 0) {
|
|
236
|
+
await addFile(
|
|
237
|
+
context,
|
|
238
|
+
path.join(resolved.root, "portal", "pages", "index.mdx"),
|
|
239
|
+
starterPage(name),
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
...(options.disabled ? { enabled: false } : {}),
|
|
246
|
+
name,
|
|
247
|
+
...(external
|
|
248
|
+
? { docs: { provider: "external" as const, url: o.docsUrl } }
|
|
249
|
+
: content.length > 0
|
|
250
|
+
? { docs: { content } }
|
|
251
|
+
: {}),
|
|
252
|
+
...(o.noConsole ? { console: { enabled: false } } : {}),
|
|
253
|
+
...(o.requireAuth ? { requireAuth: true } : {}),
|
|
254
|
+
...(o.search && !external ? { search: o.search } : {}),
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function buildGateway(
|
|
259
|
+
context: Context,
|
|
260
|
+
): Promise<NonNullable<ConfigV2["gateway"]>> {
|
|
261
|
+
const { options, dir, resolved } = context;
|
|
262
|
+
const o = options.gateway ?? {};
|
|
263
|
+
const mode =
|
|
264
|
+
o.origin ??
|
|
265
|
+
(o.url ? "proxy" : o.source || o.runtime ? "managed" : undefined);
|
|
266
|
+
if (!mode) {
|
|
267
|
+
throw new ServiceEditError(
|
|
268
|
+
"The gateway needs an origin: say how it reaches your API.",
|
|
269
|
+
"Pass --origin proxy --url <https url> for an API you host, or --origin managed --source <folder> --runtime <nodejs|python|dotnet> --runtime-version <version> to have it run your code.",
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
let origin: NonNullable<ConfigV2["gateway"]>["origin"];
|
|
273
|
+
if (mode === "proxy") {
|
|
274
|
+
if (!o.url)
|
|
275
|
+
throw new ServiceEditError("--origin proxy needs --url <https url>.");
|
|
276
|
+
origin = { mode, url: o.url };
|
|
277
|
+
} else {
|
|
278
|
+
if (!o.source || !o.runtime || !o.runtimeVersion) {
|
|
279
|
+
throw new ServiceEditError(
|
|
280
|
+
"--origin managed needs --source, --runtime and --runtime-version.",
|
|
281
|
+
"For example: --source server --runtime nodejs --runtime-version NODE_22_LTS.",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
origin = {
|
|
285
|
+
mode,
|
|
286
|
+
source: insideProject(dir, o.source, "Source folder"),
|
|
287
|
+
runtime: o.runtime,
|
|
288
|
+
runtimeVersion: o.runtimeVersion,
|
|
289
|
+
...(o.exclude?.length ? { exclude: o.exclude } : {}),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
let specs: string[];
|
|
294
|
+
if (o.spec?.length) {
|
|
295
|
+
specs = [];
|
|
296
|
+
for (const requested of o.spec) {
|
|
297
|
+
const relative = insideProject(dir, requested, "OpenAPI file");
|
|
298
|
+
if (
|
|
299
|
+
!(await stat(path.join(dir, relative)).then(
|
|
300
|
+
(s) => s.isFile(),
|
|
301
|
+
() => false,
|
|
302
|
+
))
|
|
303
|
+
) {
|
|
304
|
+
throw new ServiceEditError(
|
|
305
|
+
`OpenAPI file "${requested}" does not exist in ${dir}.`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
specs.push(relative);
|
|
309
|
+
}
|
|
310
|
+
} else if (o.createSpec) {
|
|
311
|
+
const target = path.join(resolved.apisDir, "openapi.yaml");
|
|
312
|
+
await addFile(context, target, STARTER_SPEC(resolved.project.name));
|
|
313
|
+
specs = [rel(dir, target)];
|
|
314
|
+
} else {
|
|
315
|
+
throw new ServiceEditError(
|
|
316
|
+
"The gateway needs an OpenAPI file and none was given.",
|
|
317
|
+
"Pass --spec <path> for an existing file, or --create-spec to start from a small one.",
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const auth = o.auth
|
|
322
|
+
? o.auth === "identity"
|
|
323
|
+
? { mode: "identity" as const }
|
|
324
|
+
: {
|
|
325
|
+
mode: "oidc" as const,
|
|
326
|
+
...(o.oidcMetadataUrl ? { metadataUrl: o.oidcMetadataUrl } : {}),
|
|
327
|
+
...(o.issuers?.length ? { issuers: o.issuers } : {}),
|
|
328
|
+
...(o.audiences?.length ? { audiences: o.audiences } : {}),
|
|
329
|
+
}
|
|
330
|
+
: undefined;
|
|
331
|
+
|
|
332
|
+
const rateLimit = o.rateLimit
|
|
333
|
+
? pair(o.rateLimit, "--rate-limit", "seconds")
|
|
334
|
+
: undefined;
|
|
335
|
+
const quota = o.quota ? pair(o.quota, "--quota", "days") : undefined;
|
|
336
|
+
const policies = {
|
|
337
|
+
...(rateLimit
|
|
338
|
+
? { rateLimit: { calls: rateLimit[0], periodSeconds: rateLimit[1] } }
|
|
339
|
+
: {}),
|
|
340
|
+
...(quota ? { quota: { calls: quota[0], periodDays: quota[1] } } : {}),
|
|
341
|
+
...(o.cors?.length ? { cors: o.cors } : {}),
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
...(options.disabled ? { enabled: false } : {}),
|
|
346
|
+
...(o.provider ? { provider: o.provider } : {}),
|
|
347
|
+
origin,
|
|
348
|
+
specs,
|
|
349
|
+
...(auth ? { auth } : {}),
|
|
350
|
+
...(Object.keys(policies).length > 0 ? { policies } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function buildIdentity(context: Context): NonNullable<ConfigV2["identity"]> {
|
|
355
|
+
const { options } = context;
|
|
356
|
+
const o = options.identity ?? {};
|
|
357
|
+
const provider = o.provider ?? "apitogo";
|
|
358
|
+
if (provider === "oidc") {
|
|
359
|
+
if (!o.issuer)
|
|
360
|
+
throw new ServiceEditError("--provider oidc needs --issuer <https url>.");
|
|
361
|
+
if (o.connection || o.native === false) {
|
|
362
|
+
throw new ServiceEditError(
|
|
363
|
+
"--no-native and --connection only apply to the apitogo identity provider.",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
...(options.disabled ? { enabled: false } : {}),
|
|
368
|
+
provider,
|
|
369
|
+
issuer: o.issuer,
|
|
370
|
+
...(o.clientId ? { clientId: o.clientId } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (o.issuer || o.clientId) {
|
|
374
|
+
throw new ServiceEditError(
|
|
375
|
+
"--issuer and --client-id need --provider oidc.",
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const connections = o.connection
|
|
379
|
+
? (() => {
|
|
380
|
+
if (!o.authority || !o.connectionClientId) {
|
|
381
|
+
throw new ServiceEditError(
|
|
382
|
+
"--connection needs --authority <https url> and --connection-client-id <id>.",
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
const secret = `${o.connection.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_CLIENT_SECRET`;
|
|
386
|
+
context.notes.push(
|
|
387
|
+
`Set the connection's client secret: apitogo secrets set ${secret}`,
|
|
388
|
+
);
|
|
389
|
+
return [
|
|
390
|
+
{
|
|
391
|
+
id: o.connection,
|
|
392
|
+
type: "oidc" as const,
|
|
393
|
+
authority: o.authority,
|
|
394
|
+
clientId: o.connectionClientId,
|
|
395
|
+
clientSecret: `secret:${secret}`,
|
|
396
|
+
},
|
|
397
|
+
];
|
|
398
|
+
})()
|
|
399
|
+
: undefined;
|
|
400
|
+
return {
|
|
401
|
+
...(options.disabled ? { enabled: false } : {}),
|
|
402
|
+
...(provider !== "apitogo" ? { provider } : {}),
|
|
403
|
+
...(o.native === false ? { native: false } : {}),
|
|
404
|
+
...(connections ? { connections } : {}),
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function buildPlans(
|
|
409
|
+
context: Context,
|
|
410
|
+
): Promise<NonNullable<ConfigV2["plans"]>> {
|
|
411
|
+
const { options, dir, resolved } = context;
|
|
412
|
+
const o = options.plans ?? {};
|
|
413
|
+
const template = o.template ?? "free";
|
|
414
|
+
const billing = o.billing ?? "stripe";
|
|
415
|
+
if (template === "tiered" && billing === "none") {
|
|
416
|
+
throw new ServiceEditError(
|
|
417
|
+
"The tiered template has paid plans, which need a billing provider.",
|
|
418
|
+
"Use --billing stripe, or --template free.",
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
const file = o.file ? insideProject(dir, o.file, "Plans file") : undefined;
|
|
422
|
+
const target = file
|
|
423
|
+
? path.join(dir, file)
|
|
424
|
+
: (resolved.plans?.file ?? path.join(resolved.root, "plans.json"));
|
|
425
|
+
await addFile(
|
|
426
|
+
context,
|
|
427
|
+
target,
|
|
428
|
+
`${JSON.stringify({ version: 1, plans: PLAN_TEMPLATES[template] }, null, 2)}\n`,
|
|
429
|
+
);
|
|
430
|
+
if (billing === "stripe") {
|
|
431
|
+
context.notes.push(
|
|
432
|
+
"Set the billing key: apitogo secrets set STRIPE_API_KEY",
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
...(options.disabled ? { enabled: false } : {}),
|
|
437
|
+
...(file ? { file } : {}),
|
|
438
|
+
...(billing === "stripe"
|
|
439
|
+
? {
|
|
440
|
+
billing: {
|
|
441
|
+
provider: "stripe" as const,
|
|
442
|
+
apiKey: "secret:STRIPE_API_KEY",
|
|
443
|
+
},
|
|
444
|
+
}
|
|
445
|
+
: { billing: { provider: "none" as const } }),
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function reparse(configPath: string, raw: ConfigV2): ConfigV2 {
|
|
450
|
+
// The strict parser is the one source of truth for what a valid config is.
|
|
451
|
+
return parseConfigV2(JSON.parse(JSON.stringify(raw)), configPath);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function dependencyProblems(config: ConfigV2, configPath: string) {
|
|
455
|
+
return checkDependencies(config, resolveConfigV2(config, configPath));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Decides what `add <service>` would write without touching the disk. The strict schema and the dependency rules
|
|
460
|
+
* (Reference, section 2.1) run on the new config first, so a refused add changes nothing.
|
|
461
|
+
*/
|
|
462
|
+
export async function planAddService(
|
|
463
|
+
service: ServiceName,
|
|
464
|
+
options: AddOptions,
|
|
465
|
+
): Promise<ServiceEdit> {
|
|
466
|
+
const dir = path.resolve(options.dir);
|
|
467
|
+
const { config, resolved } = loadConfigV2(dir);
|
|
468
|
+
if (config[service] !== undefined) {
|
|
469
|
+
throw new ServiceEditError(
|
|
470
|
+
`${service} is already in ${path.basename(resolved.configPath)}.`,
|
|
471
|
+
`Edit the file, or run \`apitogo remove ${service}\` first. Its files are kept.`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const context: Context = {
|
|
476
|
+
dir,
|
|
477
|
+
config,
|
|
478
|
+
resolved,
|
|
479
|
+
options,
|
|
480
|
+
files: [],
|
|
481
|
+
keep: [],
|
|
482
|
+
notes: [],
|
|
483
|
+
};
|
|
484
|
+
const section =
|
|
485
|
+
service === "portal"
|
|
486
|
+
? await buildPortal(context)
|
|
487
|
+
: service === "gateway"
|
|
488
|
+
? await buildGateway(context)
|
|
489
|
+
: service === "identity"
|
|
490
|
+
? buildIdentity(context)
|
|
491
|
+
: await buildPlans(context);
|
|
492
|
+
|
|
493
|
+
const next = reparse(resolved.configPath, { ...config, [service]: section });
|
|
494
|
+
if (!options.disabled) {
|
|
495
|
+
const before = new Set(
|
|
496
|
+
dependencyProblems(config, resolved.configPath).map(
|
|
497
|
+
(i) => i.code + i.path,
|
|
498
|
+
),
|
|
499
|
+
);
|
|
500
|
+
const added = dependencyProblems(next, resolved.configPath).filter(
|
|
501
|
+
(issue) => !before.has(issue.code + issue.path),
|
|
502
|
+
);
|
|
503
|
+
if (added.length > 0) {
|
|
504
|
+
throw new ServiceEditError(
|
|
505
|
+
added.map((issue) => issue.message).join(" "),
|
|
506
|
+
added
|
|
507
|
+
.map((issue) => issue.hint)
|
|
508
|
+
.filter(Boolean)
|
|
509
|
+
.join(" ") || "Add the missing service first.",
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return {
|
|
515
|
+
service,
|
|
516
|
+
action: "add",
|
|
517
|
+
dir,
|
|
518
|
+
configPath: resolved.configPath,
|
|
519
|
+
config: next,
|
|
520
|
+
files: context.files,
|
|
521
|
+
keep: context.keep,
|
|
522
|
+
notes: context.notes,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Decides what `remove <service>` would do: the section goes, every file stays. Refuses when another enabled
|
|
528
|
+
* service would be left without something it needs.
|
|
529
|
+
*/
|
|
530
|
+
export async function planRemoveService(
|
|
531
|
+
service: ServiceName,
|
|
532
|
+
options: { dir: string },
|
|
533
|
+
): Promise<ServiceEdit> {
|
|
534
|
+
const dir = path.resolve(options.dir);
|
|
535
|
+
const { config, resolved } = loadConfigV2(dir);
|
|
536
|
+
if (config[service] === undefined) {
|
|
537
|
+
throw new ServiceEditError(
|
|
538
|
+
`${service} is not in ${path.basename(resolved.configPath)}.`,
|
|
539
|
+
);
|
|
540
|
+
}
|
|
541
|
+
const { [service]: removed, ...rest } = config;
|
|
542
|
+
const next = reparse(resolved.configPath, rest as ConfigV2);
|
|
543
|
+
|
|
544
|
+
const before = new Set(
|
|
545
|
+
dependencyProblems(config, resolved.configPath).map((i) => i.code + i.path),
|
|
546
|
+
);
|
|
547
|
+
const caused = dependencyProblems(next, resolved.configPath).filter(
|
|
548
|
+
(issue) => !before.has(issue.code + issue.path),
|
|
549
|
+
);
|
|
550
|
+
if (caused.length > 0) {
|
|
551
|
+
throw new ServiceEditError(
|
|
552
|
+
`${service} cannot be removed while something depends on it: ${caused.map((issue) => issue.message).join(" ")}`,
|
|
553
|
+
"Remove or change the dependent service first.",
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
const left: string[] = [];
|
|
558
|
+
const r = resolved;
|
|
559
|
+
if (service === "portal" && r.portal) left.push(rel(dir, r.portal.dir));
|
|
560
|
+
if (service === "plans" && r.plans) left.push(rel(dir, r.plans.file));
|
|
561
|
+
if (service === "gateway" && r.gateway)
|
|
562
|
+
left.push(...r.gateway.specs.map((s) => rel(dir, s)));
|
|
563
|
+
void removed;
|
|
564
|
+
|
|
565
|
+
return {
|
|
566
|
+
service,
|
|
567
|
+
action: "remove",
|
|
568
|
+
dir,
|
|
569
|
+
configPath: resolved.configPath,
|
|
570
|
+
config: next,
|
|
571
|
+
files: [],
|
|
572
|
+
keep: [],
|
|
573
|
+
notes: left.length > 0 ? [`Files are kept: ${left.join(", ")}.`] : [],
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** Replaces the config file atomically: a failure leaves the old file in place. */
|
|
578
|
+
async function writeConfig(
|
|
579
|
+
configPath: string,
|
|
580
|
+
config: ConfigV2,
|
|
581
|
+
): Promise<void> {
|
|
582
|
+
const temporary = `${configPath}.${process.pid}.tmp`;
|
|
583
|
+
try {
|
|
584
|
+
await writeFile(temporary, `${JSON.stringify(config, null, 2)}\n`, "utf8");
|
|
585
|
+
await rename(temporary, configPath);
|
|
586
|
+
} catch (error) {
|
|
587
|
+
await rm(temporary, { force: true });
|
|
588
|
+
throw error;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Writes new files (never over existing ones), then replaces the config atomically. A failure leaves the old config. */
|
|
593
|
+
export async function applyServiceEdit(
|
|
594
|
+
edit: ServiceEdit,
|
|
595
|
+
): Promise<{ created: string[] }> {
|
|
596
|
+
const created: string[] = [];
|
|
597
|
+
for (const file of edit.files) {
|
|
598
|
+
await mkdir(path.dirname(file.path), { recursive: true });
|
|
599
|
+
try {
|
|
600
|
+
await writeFile(file.path, file.content, { flag: "wx" });
|
|
601
|
+
created.push(file.path);
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
await writeConfig(edit.configPath, edit.config);
|
|
607
|
+
return { created };
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The "will change" list shown before anything is written. */
|
|
611
|
+
export function formatServiceEdit(edit: ServiceEdit): string[] {
|
|
612
|
+
const verb = edit.action === "add" ? "Adds" : "Removes";
|
|
613
|
+
return [
|
|
614
|
+
`${verb} the ${edit.service} section in ${path.basename(edit.configPath)}.`,
|
|
615
|
+
...(edit.files.length > 0
|
|
616
|
+
? [
|
|
617
|
+
"Will create:",
|
|
618
|
+
...edit.files.map((file) => ` ${rel(edit.dir, file.path)}`),
|
|
619
|
+
]
|
|
620
|
+
: []),
|
|
621
|
+
...(edit.keep.length > 0
|
|
622
|
+
? [
|
|
623
|
+
`Already there, left alone: ${edit.keep.map((file) => rel(edit.dir, file)).join(", ")}`,
|
|
624
|
+
]
|
|
625
|
+
: []),
|
|
626
|
+
...edit.notes,
|
|
627
|
+
];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Writes `project.id` into the config when it has none: `deploy` and `project create` link a folder to a platform
|
|
632
|
+
* project (Reference, section 3.1). An id that is already there is never replaced.
|
|
633
|
+
*/
|
|
634
|
+
export async function setProjectId(
|
|
635
|
+
dir: string,
|
|
636
|
+
projectId: string,
|
|
637
|
+
): Promise<boolean> {
|
|
638
|
+
const { config, resolved } = loadConfigV2(dir);
|
|
639
|
+
if (config.project?.id) return false;
|
|
640
|
+
const next = reparse(resolved.configPath, {
|
|
641
|
+
...config,
|
|
642
|
+
project: { ...config.project, id: projectId },
|
|
643
|
+
});
|
|
644
|
+
await writeConfig(resolved.configPath, next);
|
|
645
|
+
return true;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** Removes `project.id` when it is the given id: the project is gone, so the link would only point at nothing. */
|
|
649
|
+
export async function clearProjectId(
|
|
650
|
+
dir: string,
|
|
651
|
+
projectId: string,
|
|
652
|
+
): Promise<boolean> {
|
|
653
|
+
const { config, resolved } = loadConfigV2(dir);
|
|
654
|
+
if (config.project?.id !== projectId) return false;
|
|
655
|
+
const { id: _id, ...project } = config.project;
|
|
656
|
+
const { project: _old, ...rest } = config;
|
|
657
|
+
const next = reparse(resolved.configPath, {
|
|
658
|
+
...rest,
|
|
659
|
+
...(Object.keys(project).length > 0 ? { project } : {}),
|
|
660
|
+
});
|
|
661
|
+
await writeConfig(resolved.configPath, next);
|
|
662
|
+
return true;
|
|
663
|
+
}
|