mirror of
https://github.com/baptisteArno/typebot.io.git
synced 2026-07-21 21:13:45 +08:00
🔧 Restore OpenAPI docs generation pipeline (#2553)
## Summary
- Restores OpenAPI docs generation, lost in the oRPC migration: new
`generate-openapi` nx targets regenerate
`apps/docs/openapi/{builder,viewer}.json` offline from the oRPC routers,
reusing the exact options served by the live `/api/openapi.json`
endpoints. Both specs are regenerated in this PR (builder.json was stale
since March).
- Fixes spec generation crashing on the spaces router (Effect Schema had
no JSON Schema converter — this also breaks the live builder
`/api/openapi.json` today) and sanitizes an `AppendValue(s)` component
name that made Mintlify silently render every builder API page empty.
- Keeps the docs from going stale again: pre-commit regenerates affected
specs and auto-stages them, CI fails PRs with stale specs, and a new
check requires lockstep `@typebot.io/js` / `@typebot.io/react` version
bumps whenever embed files change.
## Test plan
- [x] `bunx nx generate-openapi builder viewer` produces valid OpenAPI
3.1 files matching the live endpoints (works without env: isolated-vm
stubbed, dummy DATABASE_URL auto-matched to the generated Prisma client)
- [x] All 34 `openapi:` references in `apps/docs/api-reference/*.mdx`
resolve against the new specs; pages verified rendering in `mintlify
dev` (params, unions, curl samples)
- [x] Embeds version check: fails when js or react files change without
both bumps, passes on the historical 0.10.5 bump commit, ignores
unrelated changes
- [x] Pre-commit pipeline (lint, tests, generation, embeds check) passes
end to end
- [ ] Confirm the "Check OpenAPI docs are up to date" step passes on
this PR
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d039794797
commit
9d2d7ead8e
22
.github/workflows/check-embeds-version-bump.yml
vendored
Normal file
22
.github/workflows/check-embeds-version-bump.yml
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
name: Check embeds version bump
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/embeds/js/**"
|
||||
- "packages/embeds/react/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-version-bump:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Require version bump
|
||||
env:
|
||||
VERSION_BUMP_BASE_REF: ${{ github.event.pull_request.base.sha }}
|
||||
run: bash scripts/assert-embeds-version-bumped.sh
|
||||
7
.github/workflows/typecheck.yml
vendored
7
.github/workflows/typecheck.yml
vendored
@ -29,3 +29,10 @@ jobs:
|
||||
working-directory: apps/landing-page
|
||||
- run: bunx nx build @typebot.io/react --configuration=production
|
||||
- run: bunx nx affected -t typecheck --base=${{ github.event.pull_request.base.sha }}
|
||||
- name: Check OpenAPI docs are up to date
|
||||
run: |
|
||||
bunx nx run-many -t generate-openapi
|
||||
git diff --exit-code apps/docs/openapi || {
|
||||
echo "::error::OpenAPI docs are stale. Run 'bunx nx run-many -t generate-openapi' and commit the result."
|
||||
exit 1
|
||||
}
|
||||
|
||||
@ -40,6 +40,24 @@
|
||||
"cwd": "apps/builder",
|
||||
"command": "bun test"
|
||||
}
|
||||
},
|
||||
"generate-openapi": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/builder",
|
||||
"command": "SKIP_ENV_CHECK=true bun --preload ./src/scripts/preloadOpenApiGenerationStubs.ts src/scripts/generateOpenApiSpec.ts"
|
||||
},
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default",
|
||||
{
|
||||
"runtime": "grep -m1 -Eo 'provider *= *\"[a-z]+\"' node_modules/.prisma/client/schema.prisma"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
"{workspaceRoot}/apps/docs/openapi/builder.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -1,19 +1,16 @@
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||
import { onError } from "@orpc/server";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { authenticateWithBearerToken } from "@typebot.io/auth/helpers/authenticateWithBearerToken";
|
||||
import { auth } from "@typebot.io/auth/lib/nextAuth";
|
||||
import { createContext } from "@typebot.io/config/orpc/builder/context";
|
||||
import { convertSchemasListToCommonSchemas } from "@typebot.io/lib/convertSchemasListToCommonSchemas";
|
||||
import { UserId } from "@typebot.io/shared-core/domain";
|
||||
import { logServerRequest } from "@typebot.io/telemetry/logServerRequest";
|
||||
import {
|
||||
publicTypebotSchemaV5,
|
||||
publicTypebotSchemaV6,
|
||||
} from "@typebot.io/typebot/schemas/publicTypebot";
|
||||
import { typebotSchema } from "@typebot.io/typebot/schemas/typebot";
|
||||
import { after, type NextRequest } from "next/server";
|
||||
import {
|
||||
openApiSchemaConverters,
|
||||
openApiSpecGenerateOptions,
|
||||
} from "../openApiSpecGenerateOptions";
|
||||
import { appRouter } from "../router";
|
||||
|
||||
type RouteContext<_T> = {
|
||||
@ -62,35 +59,8 @@ const handler = new OpenAPIHandler(appRouter, {
|
||||
plugins: [
|
||||
new OpenAPIReferencePlugin({
|
||||
specPath: "/openapi.json",
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
specGenerateOptions: {
|
||||
filter: ({ contract }) =>
|
||||
Boolean(
|
||||
contract["~orpc"].route.method &&
|
||||
!contract["~orpc"].route.deprecated,
|
||||
),
|
||||
info: {
|
||||
title: "Builder API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: "https://app.typebot.com/api" }],
|
||||
externalDocs: {
|
||||
url: "https://docs.typebot.com/api-reference",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
commonSchemas: {
|
||||
...convertSchemasListToCommonSchemas(typebotSchema),
|
||||
"Public Typebot V5": { schema: publicTypebotSchemaV5 },
|
||||
"Public Typebot V6": { schema: publicTypebotSchemaV6 },
|
||||
},
|
||||
},
|
||||
schemaConverters: openApiSchemaConverters,
|
||||
specGenerateOptions: openApiSpecGenerateOptions,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
42
apps/builder/src/app/api/openApiSpecGenerateOptions.ts
Normal file
42
apps/builder/src/app/api/openApiSpecGenerateOptions.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import type { OpenAPIGeneratorGenerateOptions } from "@orpc/openapi";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { EffectSchemaToJsonSchemaConverter } from "@typebot.io/config/orpc/EffectSchemaToJsonSchemaConverter";
|
||||
import { convertSchemasListToCommonSchemas } from "@typebot.io/lib/convertSchemasListToCommonSchemas";
|
||||
import {
|
||||
publicTypebotSchemaV5,
|
||||
publicTypebotSchemaV6,
|
||||
} from "@typebot.io/typebot/schemas/publicTypebot";
|
||||
import { typebotSchema } from "@typebot.io/typebot/schemas/typebot";
|
||||
|
||||
export const openApiSchemaConverters = [
|
||||
new ZodToJsonSchemaConverter(),
|
||||
new EffectSchemaToJsonSchemaConverter(),
|
||||
];
|
||||
|
||||
export const openApiSpecGenerateOptions: OpenAPIGeneratorGenerateOptions = {
|
||||
filter: ({ contract }) =>
|
||||
Boolean(
|
||||
contract["~orpc"].route.method && !contract["~orpc"].route.deprecated,
|
||||
),
|
||||
info: {
|
||||
title: "Builder API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
servers: [{ url: "https://app.typebot.com/api" }],
|
||||
externalDocs: {
|
||||
url: "https://docs.typebot.com/api-reference",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Authorization: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
commonSchemas: {
|
||||
...convertSchemasListToCommonSchemas(typebotSchema),
|
||||
PublicTypebotV5: { schema: publicTypebotSchemaV5 },
|
||||
PublicTypebotV6: { schema: publicTypebotSchemaV6 },
|
||||
},
|
||||
};
|
||||
@ -13,6 +13,7 @@ export const analyticsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/analytics/inDepthData",
|
||||
operationId: "analytics-getInDepthAnalyticsData",
|
||||
summary:
|
||||
"List total answers in blocks and off-default paths visited edges",
|
||||
tags: ["Analytics"],
|
||||
@ -30,6 +31,7 @@ export const analyticsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/analytics/stats",
|
||||
operationId: "analytics-getStats",
|
||||
summary: "Get results stats",
|
||||
tags: ["Analytics"],
|
||||
})
|
||||
|
||||
@ -27,6 +27,7 @@ export const httpRequestRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/webhookBlocks",
|
||||
operationId: "httpRequest-listHttpRequestBlocks",
|
||||
summary: "List HTTP request blocks",
|
||||
description:
|
||||
"Returns a list of all the HTTP request blocks that you can subscribe to.",
|
||||
@ -56,6 +57,7 @@ export const httpRequestRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/getResultExample",
|
||||
operationId: "httpRequest-getResultExample",
|
||||
summary: "Get result example",
|
||||
description:
|
||||
'Returns "fake" result for http request block to help you anticipate how the webhook will behave.',
|
||||
@ -75,6 +77,7 @@ export const httpRequestRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/subscribe",
|
||||
operationId: "httpRequest-subscribeHttpRequest",
|
||||
summary: "Subscribe to HTTP request block",
|
||||
tags: ["HTTP request"],
|
||||
})
|
||||
@ -91,6 +94,7 @@ export const httpRequestRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/webhookBlocks/{blockId}/unsubscribe",
|
||||
operationId: "httpRequest-unsubscribeHttpRequest",
|
||||
summary: "Unsubscribe from HTTP request block",
|
||||
tags: ["HTTP request"],
|
||||
})
|
||||
|
||||
@ -35,6 +35,7 @@ export const getLinkedTypebots = authenticatedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/linkedTypebots",
|
||||
operationId: "getLinkedTypebots",
|
||||
summary: "Get linked typebots",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
|
||||
@ -43,6 +43,7 @@ export const collaboratorsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/collaborators",
|
||||
operationId: "collaborators-getCollaborators",
|
||||
summary: "Get collaborators",
|
||||
tags: ["Collaborators"],
|
||||
})
|
||||
|
||||
@ -27,6 +27,7 @@ export const customDomainsRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/custom-domains",
|
||||
operationId: "customDomains-createCustomDomain",
|
||||
summary: "Create custom domain",
|
||||
tags: ["Custom domains"],
|
||||
})
|
||||
@ -45,6 +46,7 @@ export const customDomainsRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/custom-domains/{name}",
|
||||
operationId: "customDomains-deleteCustomDomain",
|
||||
summary: "Delete custom domain",
|
||||
tags: ["Custom domains"],
|
||||
})
|
||||
@ -60,6 +62,7 @@ export const customDomainsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/custom-domains",
|
||||
operationId: "customDomains-listCustomDomains",
|
||||
summary: "List custom domains",
|
||||
tags: ["Custom domains"],
|
||||
})
|
||||
@ -80,6 +83,7 @@ export const customDomainsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/custom-domains/{name}/verify",
|
||||
operationId: "customDomains-verifyCustomDomain",
|
||||
summary: "Verify domain config",
|
||||
tags: ["Custom domains"],
|
||||
})
|
||||
|
||||
@ -21,6 +21,7 @@ export const folderRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/folders/{folderId}",
|
||||
operationId: "folders-getFolder",
|
||||
summary: "Get folder",
|
||||
tags: ["Folder"],
|
||||
})
|
||||
@ -36,6 +37,7 @@ export const folderRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/folders",
|
||||
operationId: "folders-createFolder",
|
||||
summary: "Create a folder",
|
||||
tags: ["Folder"],
|
||||
})
|
||||
@ -51,6 +53,7 @@ export const folderRouter = {
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/v1/folders/{folderId}",
|
||||
operationId: "folders-updateFolder",
|
||||
summary: "Update a folder",
|
||||
tags: ["Folder"],
|
||||
})
|
||||
@ -66,6 +69,7 @@ export const folderRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/folders/{folderId}",
|
||||
operationId: "folders-deleteFolder",
|
||||
summary: "Delete a folder",
|
||||
tags: ["Folder"],
|
||||
})
|
||||
@ -81,6 +85,7 @@ export const folderRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/folders",
|
||||
operationId: "folders-listFolders",
|
||||
summary: "List folders",
|
||||
tags: ["Folder"],
|
||||
})
|
||||
|
||||
@ -38,6 +38,7 @@ export const resultsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/results",
|
||||
operationId: "results-getResults",
|
||||
summary: "List results ordered by descending creation date",
|
||||
tags: ["Results"],
|
||||
})
|
||||
@ -54,6 +55,7 @@ export const resultsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/results/{resultId}",
|
||||
operationId: "results-getResult",
|
||||
summary: "Get result by id",
|
||||
tags: ["Results"],
|
||||
})
|
||||
@ -69,6 +71,7 @@ export const resultsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/results/{resultId}/transcript",
|
||||
operationId: "results-getResultTranscript",
|
||||
summary: "Get result transcript",
|
||||
tags: ["Results"],
|
||||
})
|
||||
@ -93,6 +96,7 @@ export const resultsRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/results/{resultId}/logs",
|
||||
operationId: "results-getResultLogs",
|
||||
summary: "List result logs",
|
||||
tags: ["Results"],
|
||||
})
|
||||
@ -104,6 +108,7 @@ export const resultsRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/typebots/{typebotId}/results",
|
||||
operationId: "results-deleteResults",
|
||||
summary: "Delete results",
|
||||
tags: ["Results"],
|
||||
})
|
||||
|
||||
@ -19,6 +19,7 @@ export const themeRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/themeTemplates",
|
||||
operationId: "theme-listThemeTemplates",
|
||||
summary: "List theme templates",
|
||||
tags: ["Theme template"],
|
||||
})
|
||||
@ -40,6 +41,7 @@ export const themeRouter = {
|
||||
.route({
|
||||
method: "PUT",
|
||||
path: "/v1/themeTemplates/{themeTemplateId}",
|
||||
operationId: "theme-saveThemeTemplate",
|
||||
summary: "Save theme template",
|
||||
tags: ["Theme template"],
|
||||
})
|
||||
@ -55,6 +57,7 @@ export const themeRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/themeTemplates/{themeTemplateId}",
|
||||
operationId: "theme-deleteThemeTemplate",
|
||||
summary: "Delete a theme template",
|
||||
tags: ["Theme template"],
|
||||
})
|
||||
|
||||
@ -60,6 +60,7 @@ const createTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots",
|
||||
operationId: "typebot-createTypebot",
|
||||
tags: ["Typebot"],
|
||||
summary: "Create a typebot",
|
||||
})
|
||||
@ -71,6 +72,7 @@ const getTypebot = publicProcedureWithOptionalUser
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}",
|
||||
operationId: "typebot-getTypebot",
|
||||
tags: ["Typebot"],
|
||||
summary: "Get a typebot",
|
||||
})
|
||||
@ -87,6 +89,7 @@ const updateTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/v1/typebots/{typebotId}",
|
||||
operationId: "typebot-updateTypebot",
|
||||
tags: ["Typebot"],
|
||||
summary: "Update a typebot",
|
||||
})
|
||||
@ -98,6 +101,7 @@ const deleteTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/typebots/{typebotId}",
|
||||
operationId: "typebot-deleteTypebot",
|
||||
summary: "Delete a typebot",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
@ -109,6 +113,7 @@ const listTypebots = authenticatedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots",
|
||||
operationId: "typebot-listTypebots",
|
||||
summary: "List typebots",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
@ -139,6 +144,7 @@ const publishTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/publish",
|
||||
operationId: "typebot-publishTypebot",
|
||||
summary: "Publish a typebot",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
@ -155,6 +161,7 @@ const unpublishTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/unpublish",
|
||||
operationId: "typebot-unpublishTypebot",
|
||||
summary: "Unpublish a typebot",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
@ -166,6 +173,7 @@ const getPublishedTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/typebots/{typebotId}/publishedTypebot",
|
||||
operationId: "typebot-getPublishedTypebot",
|
||||
summary: "Get published typebot",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
@ -190,6 +198,7 @@ const importTypebot = authenticatedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/import",
|
||||
operationId: "typebot-importTypebot",
|
||||
summary: "Import a typebot",
|
||||
tags: ["Typebot"],
|
||||
})
|
||||
|
||||
@ -28,6 +28,7 @@ export const userRouter = {
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/v1/users/me",
|
||||
operationId: "user-update",
|
||||
tags: ["User"],
|
||||
})
|
||||
.handler(handleUpdateUser),
|
||||
@ -36,6 +37,7 @@ export const userRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/users/me/api-tokens",
|
||||
operationId: "user-listApiTokens",
|
||||
tags: ["User"],
|
||||
})
|
||||
.output(
|
||||
@ -49,6 +51,7 @@ export const userRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/users/me/api-tokens",
|
||||
operationId: "user-createApiToken",
|
||||
tags: ["User"],
|
||||
})
|
||||
.input(createApiTokenInputSchema)
|
||||
@ -63,6 +66,7 @@ export const userRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/users/me/api-tokens/{tokenId}",
|
||||
operationId: "user-deleteApiToken",
|
||||
tags: ["User"],
|
||||
})
|
||||
.input(deleteApiTokenInputSchema)
|
||||
|
||||
@ -57,6 +57,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/workspaces",
|
||||
operationId: "workspace-listWorkspaces",
|
||||
summary: "List workspaces",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
@ -78,6 +79,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/workspaces/{workspaceId}",
|
||||
operationId: "workspace-getWorkspace",
|
||||
summary: "Get workspace",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
@ -94,6 +96,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/workspaces",
|
||||
operationId: "workspace-createWorkspace",
|
||||
summary: "Create workspace",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
@ -120,6 +123,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "PATCH",
|
||||
path: "/v1/workspaces/{workspaceId}",
|
||||
operationId: "workspace-updateWorkspace",
|
||||
summary: "Update workspace",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
@ -135,6 +139,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "DELETE",
|
||||
path: "/v1/workspaces/{workspaceId}",
|
||||
operationId: "workspace-deleteWorkspace",
|
||||
summary: "Delete workspace",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
@ -146,6 +151,7 @@ export const workspaceRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/workspaces/{workspaceId}/members",
|
||||
operationId: "workspace-listMembersInWorkspace",
|
||||
summary: "List members in workspace",
|
||||
tags: ["Workspace"],
|
||||
})
|
||||
|
||||
20
apps/builder/src/scripts/generateOpenApiSpec.ts
Normal file
20
apps/builder/src/scripts/generateOpenApiSpec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import {
|
||||
openApiSchemaConverters,
|
||||
openApiSpecGenerateOptions,
|
||||
} from "@/app/api/openApiSpecGenerateOptions";
|
||||
import { appRouter } from "@/app/api/router";
|
||||
|
||||
writeFileSync(
|
||||
new URL("../../../docs/openapi/builder.json", import.meta.url),
|
||||
JSON.stringify(
|
||||
await new OpenAPIGenerator({
|
||||
schemaConverters: openApiSchemaConverters,
|
||||
}).generate(appRouter, openApiSpecGenerateOptions),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
33
apps/builder/src/scripts/preloadOpenApiGenerationStubs.ts
Normal file
33
apps/builder/src/scripts/preloadOpenApiGenerationStubs.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { plugin } from "bun";
|
||||
|
||||
// OpenAPI generation only traverses contracts and never runs handlers, so
|
||||
// runtime-only dependencies can be stubbed:
|
||||
// - isolated-vm is a Node-only native addon that can't load under Bun.
|
||||
// - The Prisma client is instantiated at import time and requires a
|
||||
// DATABASE_URL whose protocol matches the generated client's provider.
|
||||
|
||||
plugin({
|
||||
name: "isolated-vm-stub",
|
||||
setup(build) {
|
||||
build.module("isolated-vm", () => ({
|
||||
exports: {
|
||||
default: {},
|
||||
Isolate: class {},
|
||||
Context: class {},
|
||||
Reference: class {},
|
||||
},
|
||||
loader: "object",
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
process.env.DATABASE_URL = readFileSync(
|
||||
new URL(
|
||||
"../../../../node_modules/.prisma/client/schema.prisma",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf-8",
|
||||
).match(/provider\s*=\s*"mysql"/)
|
||||
? "mysql://user:password@localhost:3306/typebot"
|
||||
: "postgresql://user:password@localhost:5432/typebot";
|
||||
@ -1,4 +1,4 @@
|
||||
---
|
||||
title: 'Continue chat'
|
||||
openapi: POST /v1/sessions/{sessionId}/continueChat
|
||||
openapi: /openapi/viewer.json POST /v1/sessions/{sessionId}/continueChat
|
||||
---
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 'Generate upload URL'
|
||||
openapi: POST /v3/generate-upload-url
|
||||
openapi: /openapi/viewer.json POST /v3/generate-upload-url
|
||||
---
|
||||
|
||||
The `presignedUrl` and `formData` fields can be then used to upload the file to the S3 bucket, directly from the browser if necessary. Here is an example:
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
---
|
||||
title: 'Start preview chat'
|
||||
openapi: POST /v1/typebots/{typebotId}/preview/startChat
|
||||
openapi: /openapi/viewer.json POST /v1/typebots/{typebotId}/preview/startChat
|
||||
---
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -43,6 +43,24 @@
|
||||
"cwd": "apps/viewer",
|
||||
"command": "playwright test"
|
||||
}
|
||||
},
|
||||
"generate-openapi": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"cwd": "apps/viewer",
|
||||
"command": "SKIP_ENV_CHECK=true bun --preload ./src/scripts/preloadOpenApiGenerationStubs.ts src/scripts/generateOpenApiSpec.ts"
|
||||
},
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"default",
|
||||
"^default",
|
||||
{
|
||||
"runtime": "grep -m1 -Eo 'provider *= *\"[a-z]+\"' node_modules/.prisma/client/schema.prisma"
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
"{workspaceRoot}/apps/docs/openapi/viewer.json"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -2,18 +2,14 @@ import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins";
|
||||
import { onError } from "@orpc/server";
|
||||
import { CORSPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { authenticateWithBearerToken } from "@typebot.io/auth/helpers/authenticateWithBearerToken";
|
||||
import {
|
||||
audioMessageSchema,
|
||||
commandMessageSchema,
|
||||
startFromEventSchema,
|
||||
startFromGroupSchema,
|
||||
textMessageSchema,
|
||||
} from "@typebot.io/chat-api/schemas";
|
||||
import { createContext } from "@typebot.io/config/orpc/viewer/context";
|
||||
import { logServerRequest } from "@typebot.io/telemetry/logServerRequest";
|
||||
import { after, type NextRequest } from "next/server";
|
||||
import {
|
||||
openApiSchemaConverters,
|
||||
openApiSpecGenerateOptions,
|
||||
} from "../openApiSpecGenerateOptions";
|
||||
import { appRouter } from "./router";
|
||||
|
||||
type RouteContext<_T> = {
|
||||
@ -62,47 +58,8 @@ const handler = new OpenAPIHandler(appRouter, {
|
||||
new CORSPlugin(),
|
||||
new OpenAPIReferencePlugin({
|
||||
specPath: "/openapi.json",
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
specGenerateOptions: {
|
||||
filter: ({ contract }) =>
|
||||
Boolean(
|
||||
contract["~orpc"].route.method &&
|
||||
!contract["~orpc"].route.deprecated,
|
||||
),
|
||||
info: {
|
||||
title: "Chat API",
|
||||
version: "3.0.0",
|
||||
},
|
||||
servers: [{ url: "https://typebot.io/api" }],
|
||||
externalDocs: {
|
||||
url: "https://docs.typebot.com/api-reference",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
commonSchemas: {
|
||||
Text: {
|
||||
schema: textMessageSchema,
|
||||
},
|
||||
Audio: {
|
||||
schema: audioMessageSchema,
|
||||
},
|
||||
Command: {
|
||||
schema: commandMessageSchema,
|
||||
},
|
||||
Group: {
|
||||
schema: startFromGroupSchema,
|
||||
},
|
||||
Event: {
|
||||
schema: startFromEventSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
schemaConverters: openApiSchemaConverters,
|
||||
specGenerateOptions: openApiSpecGenerateOptions,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
55
apps/viewer/src/app/api/openApiSpecGenerateOptions.ts
Normal file
55
apps/viewer/src/app/api/openApiSpecGenerateOptions.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { OpenAPIGeneratorGenerateOptions } from "@orpc/openapi";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import {
|
||||
audioMessageSchema,
|
||||
commandMessageSchema,
|
||||
startFromEventSchema,
|
||||
startFromGroupSchema,
|
||||
textMessageSchema,
|
||||
} from "@typebot.io/chat-api/schemas";
|
||||
import { EffectSchemaToJsonSchemaConverter } from "@typebot.io/config/orpc/EffectSchemaToJsonSchemaConverter";
|
||||
|
||||
export const openApiSchemaConverters = [
|
||||
new ZodToJsonSchemaConverter(),
|
||||
new EffectSchemaToJsonSchemaConverter(),
|
||||
];
|
||||
|
||||
export const openApiSpecGenerateOptions: OpenAPIGeneratorGenerateOptions = {
|
||||
filter: ({ contract }) =>
|
||||
Boolean(
|
||||
contract["~orpc"].route.method && !contract["~orpc"].route.deprecated,
|
||||
),
|
||||
info: {
|
||||
title: "Chat API",
|
||||
version: "3.0.0",
|
||||
},
|
||||
servers: [{ url: "https://typebot.io/api" }],
|
||||
externalDocs: {
|
||||
url: "https://docs.typebot.com/api-reference",
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
Authorization: {
|
||||
type: "http",
|
||||
scheme: "bearer",
|
||||
},
|
||||
},
|
||||
},
|
||||
commonSchemas: {
|
||||
Text: {
|
||||
schema: textMessageSchema,
|
||||
},
|
||||
Audio: {
|
||||
schema: audioMessageSchema,
|
||||
},
|
||||
Command: {
|
||||
schema: commandMessageSchema,
|
||||
},
|
||||
Group: {
|
||||
schema: startFromGroupSchema,
|
||||
},
|
||||
Event: {
|
||||
schema: startFromEventSchema,
|
||||
},
|
||||
},
|
||||
};
|
||||
20
apps/viewer/src/scripts/generateOpenApiSpec.ts
Normal file
20
apps/viewer/src/scripts/generateOpenApiSpec.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { appRouter } from "@/app/api/[[...rest]]/router";
|
||||
import {
|
||||
openApiSchemaConverters,
|
||||
openApiSpecGenerateOptions,
|
||||
} from "@/app/api/openApiSpecGenerateOptions";
|
||||
|
||||
writeFileSync(
|
||||
new URL("../../../docs/openapi/viewer.json", import.meta.url),
|
||||
JSON.stringify(
|
||||
await new OpenAPIGenerator({
|
||||
schemaConverters: openApiSchemaConverters,
|
||||
}).generate(appRouter, openApiSpecGenerateOptions),
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
33
apps/viewer/src/scripts/preloadOpenApiGenerationStubs.ts
Normal file
33
apps/viewer/src/scripts/preloadOpenApiGenerationStubs.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { plugin } from "bun";
|
||||
|
||||
// OpenAPI generation only traverses contracts and never runs handlers, so
|
||||
// runtime-only dependencies can be stubbed:
|
||||
// - isolated-vm is a Node-only native addon that can't load under Bun.
|
||||
// - The Prisma client is instantiated at import time and requires a
|
||||
// DATABASE_URL whose protocol matches the generated client's provider.
|
||||
|
||||
plugin({
|
||||
name: "isolated-vm-stub",
|
||||
setup(build) {
|
||||
build.module("isolated-vm", () => ({
|
||||
exports: {
|
||||
default: {},
|
||||
Isolate: class {},
|
||||
Context: class {},
|
||||
Reference: class {},
|
||||
},
|
||||
loader: "object",
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
process.env.DATABASE_URL = readFileSync(
|
||||
new URL(
|
||||
"../../../../node_modules/.prisma/client/schema.prisma",
|
||||
import.meta.url,
|
||||
),
|
||||
"utf-8",
|
||||
).match(/provider\s*=\s*"mysql"/)
|
||||
? "mysql://user:password@localhost:3306/typebot"
|
||||
: "postgresql://user:password@localhost:5432/typebot";
|
||||
@ -29,7 +29,7 @@
|
||||
"scripts": {
|
||||
"postinstall": "cd packages/env && bun run compile && nx db:generate prisma",
|
||||
"prepare": "husky && effect-language-service patch",
|
||||
"pre-commit": "nx affected -t format-and-lint,lint-repo,check-broken-links,test --parallel=4",
|
||||
"pre-commit": "bash scripts/assert-embeds-version-bumped.sh && nx affected -t format-and-lint,lint-repo,check-broken-links,test,generate-openapi --parallel=4 && git add apps/docs/openapi",
|
||||
"dev": "nx run-many --configuration=development -t dev,watch-deps -p builder,viewer,workflows,@typebot.io/partykit",
|
||||
"format-and-lint": "biome check .",
|
||||
"format-and-lint:fix": "biome check . --write --unsafe",
|
||||
|
||||
@ -57,6 +57,7 @@ export const billingRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/billing/usage",
|
||||
operationId: "billing-getUsage",
|
||||
summary: "Get current plan usage",
|
||||
tags: ["Billing"],
|
||||
})
|
||||
@ -68,6 +69,7 @@ export const billingRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/billing/invoices",
|
||||
operationId: "billing-listInvoices",
|
||||
summary: "List invoices",
|
||||
tags: ["Billing"],
|
||||
})
|
||||
|
||||
@ -56,6 +56,7 @@ export const fileUploadViewerRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v3/generate-upload-url",
|
||||
operationId: "generateUploadUrl",
|
||||
summary: "Generate upload URL",
|
||||
description: "Used to upload anything from the client to S3 bucket",
|
||||
tags: ["File upload"],
|
||||
|
||||
@ -70,6 +70,7 @@ export const chatRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{publicId}/startChat",
|
||||
operationId: "startChat",
|
||||
summary: "Start chat",
|
||||
tags: ["Chat"],
|
||||
})
|
||||
@ -80,6 +81,7 @@ export const chatRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/sessions/{sessionId}/continueChat",
|
||||
operationId: "continueChat",
|
||||
summary: "Continue chat",
|
||||
tags: ["Chat"],
|
||||
})
|
||||
@ -90,6 +92,7 @@ export const chatRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v2/sessions/{sessionId}/clientLogs",
|
||||
operationId: "saveClientLogs",
|
||||
summary: "Save logs",
|
||||
tags: ["Chat"],
|
||||
})
|
||||
@ -100,6 +103,7 @@ export const chatRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/preview/startChat",
|
||||
operationId: "startChatPreview",
|
||||
summary: "Start preview chat",
|
||||
description:
|
||||
'Use this endpoint to test your bot. The answers will not be saved. And some blocks like "Send email" will be skipped.',
|
||||
@ -112,6 +116,7 @@ export const chatRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/sessions/{sessionId}/updateTypebot",
|
||||
operationId: "updateTypebotInSession",
|
||||
summary: "Update typebot in session",
|
||||
description:
|
||||
"Update chat session with latest typebot modifications. This is useful when you want to update the typebot in an ongoing session after making changes to it.",
|
||||
|
||||
@ -0,0 +1,32 @@
|
||||
import type { AnySchema } from "@orpc/contract";
|
||||
import type {
|
||||
ConditionalSchemaConverter,
|
||||
JSONSchema,
|
||||
SchemaConvertOptions,
|
||||
} from "@orpc/openapi";
|
||||
import { Schema } from "effect";
|
||||
|
||||
export class EffectSchemaToJsonSchemaConverter
|
||||
implements ConditionalSchemaConverter
|
||||
{
|
||||
condition(schema: AnySchema | undefined): boolean {
|
||||
return schema !== undefined && schema["~standard"].vendor === "effect";
|
||||
}
|
||||
|
||||
convert(
|
||||
schema: AnySchema | undefined,
|
||||
_options: SchemaConvertOptions,
|
||||
): [required: boolean, jsonSchema: JSONSchema] {
|
||||
if (!Schema.isSchema(schema))
|
||||
throw new Error(
|
||||
"Schemas with the effect vendor are expected to be Effect schemas",
|
||||
);
|
||||
const document = Schema.toJsonSchemaDocument(schema);
|
||||
return [
|
||||
true,
|
||||
Object.keys(document.definitions).length > 0
|
||||
? { ...document.schema, $defs: document.definitions }
|
||||
: document.schema,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,13 @@ import * as Sentry from "@sentry/nextjs";
|
||||
import { env } from "@typebot.io/env";
|
||||
import type { Context } from "./context";
|
||||
|
||||
export const os = baseOs.$context<Context>();
|
||||
export const os = baseOs.$context<Context>().errors({
|
||||
BAD_REQUEST: {},
|
||||
UNAUTHORIZED: {},
|
||||
FORBIDDEN: {},
|
||||
NOT_FOUND: {},
|
||||
INTERNAL_SERVER_ERROR: {},
|
||||
});
|
||||
|
||||
const webhookUrlPaths = [
|
||||
"builderWhatsAppRouter/previewWebhookProcedure",
|
||||
@ -61,7 +67,7 @@ const requireAuth = oo.spec(
|
||||
});
|
||||
}),
|
||||
{
|
||||
security: [{ bearerAuth: [] }],
|
||||
security: [{ Authorization: [] }],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -3,7 +3,13 @@ import { os as baseOs, ORPCError } from "@orpc/server";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { Context } from "./context";
|
||||
|
||||
export const os = baseOs.$context<Context>();
|
||||
export const os = baseOs.$context<Context>().errors({
|
||||
BAD_REQUEST: {},
|
||||
UNAUTHORIZED: {},
|
||||
FORBIDDEN: {},
|
||||
NOT_FOUND: {},
|
||||
INTERNAL_SERVER_ERROR: {},
|
||||
});
|
||||
|
||||
const webhookUrlPaths = [
|
||||
"chatWhatsAppRouter/productionWebhookProcedure",
|
||||
@ -90,7 +96,7 @@ const requireAuth = oo.spec(
|
||||
});
|
||||
}),
|
||||
{
|
||||
security: [{ bearerAuth: [] }],
|
||||
security: [{ Authorization: [] }],
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -173,7 +173,9 @@ const toPascalCase = (str: string): string =>
|
||||
str
|
||||
.split(/[-_\s]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
||||
.join("");
|
||||
.join("")
|
||||
// OpenAPI component keys must match ^[a-zA-Z0-9._-]+$
|
||||
.replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
|
||||
type ZodObjectAny = z.ZodObject<z.ZodRawShape>;
|
||||
|
||||
|
||||
@ -56,6 +56,7 @@ export const chatWhatsAppRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/workspaces/{workspaceId}/whatsapp/{credentialsId}/webhook",
|
||||
operationId: "whatsAppRouter-subscribeWebhook",
|
||||
summary: "Subscribe webhook",
|
||||
tags: ["WhatsApp"],
|
||||
})
|
||||
@ -89,6 +90,7 @@ export const builderWhatsAppRouter = {
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/v1/typebots/{typebotId}/whatsapp/start-preview",
|
||||
operationId: "whatsApp-startWhatsAppPreview",
|
||||
summary: "Start preview",
|
||||
tags: ["WhatsApp"],
|
||||
})
|
||||
@ -104,6 +106,7 @@ export const builderWhatsAppRouter = {
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/v1/whatsapp/preview/webhook",
|
||||
operationId: "whatsApp-subscribePreviewWebhook",
|
||||
summary: "Subscribe webhook",
|
||||
tags: ["WhatsApp"],
|
||||
})
|
||||
|
||||
56
scripts/assert-embeds-version-bumped.sh
Executable file
56
scripts/assert-embeds-version-bumped.sh
Executable file
@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env bash
|
||||
# Fails when files under packages/embeds/js or packages/embeds/react changed
|
||||
# without bumping both package versions to the same value: they are published
|
||||
# in lockstep since @typebot.io/react bundles @typebot.io/js.
|
||||
#
|
||||
# Changed files are detected against the merge base with main (or
|
||||
# VERSION_BUMP_BASE_REF), while versions must be strictly greater than the
|
||||
# ones at the base ref tip so a bump that races another merged release is
|
||||
# rejected too.
|
||||
set -euo pipefail
|
||||
|
||||
base_ref="${VERSION_BUMP_BASE_REF:-$(git rev-parse --verify -q origin/main >/dev/null && echo origin/main || echo main)}"
|
||||
merge_base=$(git merge-base HEAD "$base_ref")
|
||||
|
||||
changed() {
|
||||
git diff --name-only "$merge_base" -- "$1" | grep -q .
|
||||
}
|
||||
|
||||
bumped() {
|
||||
local base_version current_version
|
||||
base_version=$(git show "$base_ref:packages/embeds/$1/package.json" | extract_version)
|
||||
current_version=$(current_version_of "$1")
|
||||
node -e "
|
||||
const [base, current] = process.argv.slice(1).map((v) => v.split('.').map(Number));
|
||||
const isGreater = current[0] !== base[0] ? current[0] > base[0] : current[1] !== base[1] ? current[1] > base[1] : current[2] > base[2];
|
||||
process.exit(isGreater ? 0 : 1);
|
||||
" "$base_version" "$current_version"
|
||||
}
|
||||
|
||||
current_version_of() {
|
||||
extract_version <"packages/embeds/$1/package.json"
|
||||
}
|
||||
|
||||
extract_version() {
|
||||
node -e "let s='';process.stdin.on('data',(c)=>{s+=c}).on('end',()=>{console.log(JSON.parse(s).version)})"
|
||||
}
|
||||
|
||||
require_bump() {
|
||||
if bumped "$1"; then
|
||||
echo "@typebot.io/$1: version bump detected"
|
||||
else
|
||||
echo "Embed sources changed but @typebot.io/$1 was not bumped above its version on $base_ref. Bump the versions in both packages/embeds/js and packages/embeds/react package.json files: they are published in lockstep since @typebot.io/react bundles @typebot.io/js."
|
||||
failed=1
|
||||
fi
|
||||
}
|
||||
|
||||
failed=0
|
||||
if changed packages/embeds/js || changed packages/embeds/react; then
|
||||
require_bump js
|
||||
require_bump react
|
||||
if [ "$(current_version_of js)" != "$(current_version_of react)" ]; then
|
||||
echo "@typebot.io/js ($(current_version_of js)) and @typebot.io/react ($(current_version_of react)) must be bumped to the same version: they are published in lockstep."
|
||||
failed=1
|
||||
fi
|
||||
fi
|
||||
exit $failed
|
||||
Loading…
Reference in New Issue
Block a user