feat: add deployments api routes

pull/715/head
Travis Fischer 2025-05-19 01:36:04 +07:00
rodzic 4e946fccd5
commit 9c0975f170
16 zmienionych plików z 570 dodań i 60 usunięć

Wyświetl plik

@ -54,6 +54,7 @@
"p-all": "^5.0.0",
"postgres": "^3.4.5",
"restore-cursor": "catalog:",
"semver": "^7.7.2",
"stripe": "^18.1.0",
"type-fest": "catalog:",
"zod": "catalog:",
@ -61,6 +62,7 @@
},
"devDependencies": {
"@types/jsonwebtoken": "^9.0.9",
"@types/semver": "^7.7.0",
"drizzle-kit": "^0.31.1",
"drizzle-orm": "^0.43.1"
}

Wyświetl plik

@ -16,7 +16,7 @@ import { consumerIdParamsSchema } from './schemas'
const route = createRoute({
description:
"Updates a consumer's subscription to a different deployment or pricing plan.",
"Updates a consumer's subscription to a different deployment or pricing plan. Set `plan` to undefined to cancel the subscription.",
tags: ['consumers'],
operationId: 'updateConsumer',
method: 'post',
@ -28,7 +28,7 @@ const route = createRoute({
required: true,
content: {
'application/json': {
schema: schema.consumerInsertSchema
schema: schema.consumerUpdateSchema
}
}
}

Wyświetl plik

@ -0,0 +1,131 @@
import { validators } from '@agentic/validators'
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import semver from 'semver'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponse409,
openapiErrorResponse410,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { assert, parseZodSchema, sha256 } from '@/lib/utils'
const route = createRoute({
description: 'Creates a new deployment within a project.',
tags: ['deployments'],
operationId: 'createDeployment',
method: 'post',
path: 'deployments',
security: openapiAuthenticatedSecuritySchemas,
request: {
body: {
required: true,
content: {
'application/json': {
schema: schema.deploymentInsertSchema
}
}
}
},
responses: {
200: {
description: 'A deployment object',
content: {
'application/json': {
schema: schema.deploymentSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404,
...openapiErrorResponse409,
...openapiErrorResponse410
}
})
export function registerV1DeploymentsCreateDeployment(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const user = await ensureAuthUser(c)
const body = c.req.valid('json')
const teamMember = c.get('teamMember')
const { projectId } = body
// validatePricingPlans(ctx, pricingPlans)
// TODO: OpenAPI support
const project = await db.query.projects.findFirst({
where: eq(schema.projects.id, projectId),
with: {
lastPublishedDeployment: true
}
})
assert(project, 404, `Project not found "${projectId}"`)
await acl(c, project, { label: 'Project' })
// TODO: investigate better short hash generation
const hash = sha256().slice(0, 8)
const deploymentId = `${project.id}@${hash}`
assert(
validators.deploymentId(deploymentId),
400,
`Invalid deployment id "${deploymentId}"`
)
let { version } = body
if (version) {
version = semver.clean(version) ?? undefined
assert(
version && semver.valid(version),
400,
`Invalid semver version "${version}"`
)
const lastPublishedVersion =
project.lastPublishedDeployment?.version ?? '0.0.0'
assert(
semver.gt(version, lastPublishedVersion),
400,
`Semver version "${version}" must be greater than last published version "${lastPublishedVersion}"`
)
}
const [[deployment]] = await db.transaction(async (tx) => {
return Promise.all([
// Create the deployment
tx
.insert(schema.deployments)
.values({
...body,
id: deploymentId,
hash,
userId: user.id,
teamId: teamMember?.teamId,
projectId,
version
})
.returning(),
// Update the project
tx
.update(schema.projects)
.set({
lastDeploymentId: deploymentId
})
.where(eq(schema.projects.id, projectId))
])
})
assert(deployment, 500, `Failed to create deployment "${deploymentId}"`)
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -0,0 +1,58 @@
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { schema } from '@/db'
import { acl } from '@/lib/acl'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { tryGetDeployment } from '@/lib/try-get-deployment'
import { assert, parseZodSchema } from '@/lib/utils'
import { deploymentIdParamsSchema, populateDeploymentSchema } from './schemas'
const route = createRoute({
description: 'Gets a deployment',
tags: ['deployments'],
operationId: 'getdeployment',
method: 'get',
path: 'deployments/{deploymentId}',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: deploymentIdParamsSchema,
query: populateDeploymentSchema
},
responses: {
200: {
description: 'A deployment object',
content: {
'application/json': {
schema: schema.deploymentSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
export function registerV1DeploymentsGetDeployment(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const { deploymentId } = c.req.valid('param')
const { populate = [] } = c.req.valid('query')
const deployment = await tryGetDeployment(c, deploymentId, {
with: {
...Object.fromEntries(populate.map((field) => [field, true]))
}
})
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
await acl(c, deployment, { label: 'Deployment' })
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -0,0 +1,73 @@
import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { and, db, eq, schema } from '@/db'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { parseZodSchema } from '@/lib/utils'
import { paginationAndPopulateAndFilterDeploymentSchema } from './schemas'
const route = createRoute({
description: 'Lists deployments the user or team has access to.',
tags: ['deployments'],
operationId: 'listDeployments',
method: 'get',
path: 'deployments',
security: openapiAuthenticatedSecuritySchemas,
request: {
query: paginationAndPopulateAndFilterDeploymentSchema
},
responses: {
200: {
description: 'A list of deployments',
content: {
'application/json': {
schema: z.array(schema.deploymentSelectSchema)
}
}
},
...openapiErrorResponses
}
})
export function registerV1DeploymentsListDeployments(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const {
offset = 0,
limit = 10,
sort = 'desc',
sortBy = 'createdAt',
populate = [],
projectId
} = c.req.valid('query')
const userId = c.get('userId')
const teamMember = c.get('teamMember')
const deployments = await db.query.deployments.findMany({
where: and(
teamMember
? eq(schema.deployments.teamId, teamMember.teamId)
: eq(schema.deployments.userId, userId),
projectId ? eq(schema.deployments.projectId, projectId) : undefined
),
with: {
...Object.fromEntries(populate.map((field) => [field, true]))
},
orderBy: (deployments, { asc, desc }) => [
sort === 'desc' ? desc(deployments[sortBy]) : asc(deployments[sortBy])
],
offset,
limit
})
return c.json(
parseZodSchema(z.array(schema.deploymentSelectSchema), deployments)
)
})
}

Wyświetl plik

@ -0,0 +1,118 @@
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import semver from 'semver'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { tryGetDeployment } from '@/lib/try-get-deployment'
import { assert, parseZodSchema } from '@/lib/utils'
import { deploymentIdParamsSchema } from './schemas'
const route = createRoute({
description: 'Publishes a deployment.',
tags: ['deployments'],
operationId: 'publishDeployment',
method: 'post',
path: 'deployments/{deploymentId}/publish',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: deploymentIdParamsSchema,
body: {
required: true,
content: {
'application/json': {
schema: schema.deploymentPublishSchema
}
}
}
},
responses: {
200: {
description: 'A deployment object',
content: {
'application/json': {
schema: schema.deploymentSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
export function registerV1DeploymentsPublishDeployment(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const { deploymentId } = c.req.valid('param')
const body = c.req.valid('json')
const version = semver.clean(body.version)
assert(version, 400, `Invalid semver version "${body.version}"`)
// First ensure the deployment exists and the user has access to it
let deployment = await tryGetDeployment(c, deploymentId)
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
await acl(c, deployment, { label: 'Deployment' })
const project = await db.query.projects.findFirst({
where: eq(schema.projects.id, deployment.projectId),
with: {
lastPublishedDeployment: true
}
})
assert(project, 404, `Project not found "${deployment.projectId}"`)
await acl(c, project, { label: 'Project' })
const lastPublishedVersion =
project.lastPublishedDeployment?.version || '0.0.0'
assert(
semver.valid(version),
400,
`Invalid semver version "${version}" for deployment "${deployment.id}"`
)
assert(
semver.gt(version, lastPublishedVersion),
400,
`Invalid semver version: "${version}" must be greater than current published version "${lastPublishedVersion}" for deployment "${deployment.id}"`
)
// TODO: enforce certain semver constraints
// - pricing changes require major version update
// - deployment shouldn't already be published?
// - any others?
// Update the deployment and project together in a transaction
;[[deployment]] = await db.transaction(async (tx) => {
return Promise.all([
// Update the deployment
tx
.update(schema.deployments)
.set({
published: true,
version
})
.where(eq(schema.deployments.id, deploymentId))
.returning(),
tx
.update(schema.projects)
.set({
lastPublishedDeploymentId: deploymentId
})
.where(eq(schema.projects.id, project.id))
// TODO: add publishDeploymentLogEntry
])
})
assert(deployment, 500, `Failed to update deployment "${deploymentId}"`)
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -0,0 +1,28 @@
import { z } from '@hono/zod-openapi'
import { deploymentIdSchema, paginationSchema, projectIdSchema } from '@/db'
import { deploymentRelationsSchema } from '@/db/schema'
export const deploymentIdParamsSchema = z.object({
deploymentId: deploymentIdSchema.openapi({
param: {
description: 'deployment ID',
name: 'deploymentId',
in: 'path'
}
})
})
export const filterDeploymentSchema = z.object({
projectId: projectIdSchema.optional()
})
export const populateDeploymentSchema = z.object({
populate: z.array(deploymentRelationsSchema).default([]).optional()
})
export const paginationAndPopulateAndFilterDeploymentSchema = z.object({
...paginationSchema.shape,
...populateDeploymentSchema.shape,
...filterDeploymentSchema.shape
})

Wyświetl plik

@ -0,0 +1,70 @@
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,
openapiErrorResponses
} from '@/lib/openapi-utils'
import { tryGetDeployment } from '@/lib/try-get-deployment'
import { assert, parseZodSchema } from '@/lib/utils'
import { deploymentIdParamsSchema } from './schemas'
const route = createRoute({
description: 'Updates a deployment.',
tags: ['deployments'],
operationId: 'updateDeployment',
method: 'post',
path: 'deployments/{deploymentId}',
security: openapiAuthenticatedSecuritySchemas,
request: {
params: deploymentIdParamsSchema,
body: {
required: true,
content: {
'application/json': {
schema: schema.deploymentUpdateSchema
}
}
}
},
responses: {
200: {
description: 'A deployment object',
content: {
'application/json': {
schema: schema.deploymentSelectSchema
}
}
},
...openapiErrorResponses,
...openapiErrorResponse404
}
})
export function registerV1DeploymentsUpdateDeployment(
app: OpenAPIHono<AuthenticatedEnv>
) {
return app.openapi(route, async (c) => {
const { deploymentId } = c.req.valid('param')
const body = c.req.valid('json')
// First ensure the deployment exists and the user has access to it
let deployment = await tryGetDeployment(c, deploymentId)
assert(deployment, 404, `Deployment not found "${deploymentId}"`)
await acl(c, deployment, { label: 'Deployment' })
// Update the deployment
;[deployment] = await db
.update(schema.deployments)
.set(body)
.where(eq(schema.deployments.id, deploymentId))
.returning()
assert(deployment, 500, `Failed to update deployment "${deploymentId}"`)
return c.json(parseZodSchema(schema.deploymentSelectSchema, deployment))
})
}

Wyświetl plik

@ -10,6 +10,11 @@ import { registerV1ConsumersGetConsumer } from './consumers/get-consumer'
import { registerV1ProjectsListConsumers } from './consumers/list-consumers'
import { registerV1ConsumersRefreshConsumerToken } from './consumers/refresh-consumer-token'
import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer'
import { registerV1DeploymentsCreateDeployment } from './deployments/create-deployment'
import { registerV1DeploymentsGetDeployment } from './deployments/get-deployment'
import { registerV1DeploymentsListDeployments } from './deployments/list-deployments'
import { registerV1DeploymentsPublishDeployment } from './deployments/publish-deployment'
import { registerV1DeploymentsUpdateDeployment } from './deployments/update-deployment'
import { registerHealthCheck } from './health-check'
import { registerV1ProjectsCreateProject } from './projects/create-project'
import { registerV1ProjectsGetProject } from './projects/get-project'
@ -76,17 +81,6 @@ registerV1ProjectsListProjects(privateRouter)
registerV1ProjectsGetProject(privateRouter)
registerV1ProjectsUpdateProject(privateRouter)
// TODO
// pub.get('/projects/alias/:alias(.+)', require('./projects').readByAlias)
// pri.get('/projects/provider/:project(.+)', require('./provider').read)
// pri.put('/projects/provider/:project(.+)', require('./provider').update)
// pri.put('/projects/connect/:project(.+)', require('./projects').connect)
// pub.get(
// '/projects/:project(.+)',
// middleware.authenticate({ passthrough: true }),
// require('./projects').read
// )
// Consumers
registerV1ConsumersGetConsumer(privateRouter)
registerV1ConsumersCreateConsumer(privateRouter)
@ -94,6 +88,13 @@ registerV1ConsumersUpdateConsumer(privateRouter)
registerV1ConsumersRefreshConsumerToken(privateRouter)
registerV1ProjectsListConsumers(privateRouter)
// Deployments
registerV1DeploymentsGetDeployment(privateRouter)
registerV1DeploymentsCreateDeployment(privateRouter)
registerV1DeploymentsUpdateDeployment(privateRouter)
registerV1DeploymentsListDeployments(privateRouter)
registerV1DeploymentsPublishDeployment(privateRouter)
// Webhook event handlers
registerV1StripeWebhook(publicRouter)
@ -135,3 +136,9 @@ export type ApiRoutes =
| ReturnType<typeof registerV1ConsumersUpdateConsumer>
| ReturnType<typeof registerV1ConsumersRefreshConsumerToken>
| ReturnType<typeof registerV1ProjectsListConsumers>
// Deployments
| ReturnType<typeof registerV1DeploymentsGetDeployment>
| ReturnType<typeof registerV1DeploymentsCreateDeployment>
| ReturnType<typeof registerV1DeploymentsUpdateDeployment>
| ReturnType<typeof registerV1DeploymentsListDeployments>
| ReturnType<typeof registerV1DeploymentsPublishDeployment>

Wyświetl plik

@ -2,7 +2,6 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { db, schema } from '@/db'
import { aclTeamMember } from '@/lib/acl-team-member'
import { createProviderToken } from '@/lib/auth/create-provider-token'
import { ensureAuthUser } from '@/lib/ensure-auth-user'
import {
@ -48,9 +47,9 @@ export function registerV1ProjectsCreateProject(
const body = c.req.valid('json')
const user = await ensureAuthUser(c)
if (body.teamId) {
await aclTeamMember(c, { teamId: body.teamId })
}
// if (body.teamId) {
// await aclTeamMember(c, { teamId: body.teamId })
// }
const teamMember = c.get('teamMember')
const namespace = teamMember ? teamMember.teamSlug : user.username

Wyświetl plik

@ -62,7 +62,7 @@ export const deployments = pgTable(
// TODO: third-party auth provider config?
// Backend API URL
_url: text().notNull(),
originUrl: text().notNull(),
// Array<PricingPlan>
pricingPlans: jsonb().$type<PricingPlanList>().notNull()
@ -96,6 +96,13 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
})
}))
export type DeploymentRelationFields = keyof ReturnType<
(typeof deploymentsRelations)['config']
>
export const deploymentRelationsSchema: z.ZodType<DeploymentRelationFields> =
z.enum(['user', 'team', 'project'])
// TODO: virtual hasFreeTier
// TODO: virtual url
// TODO: virtual openApiUrl
@ -106,28 +113,6 @@ export const deploymentsRelations = relations(deployments, ({ one }) => ({
export const deploymentSelectSchema = createSelectSchema(deployments, {
// build: z.object({}),
// env: z.object({}),
pricingPlans: pricingPlanListSchema
// coupons: z.array(couponSchema)
})
.omit({
_url: true
})
.extend({
user: z
.lazy(() => userSelectSchema)
.optional()
.openapi('User', { type: 'object' }),
team: z
.lazy(() => teamSelectSchema)
.optional()
.openapi('Team', { type: 'object' })
})
.strip()
.openapi('Deployment')
export const deploymentInsertSchema = createInsertSchema(deployments, {
id: (schema) =>
schema.refine((id) => validators.deploymentId(id), {
message: 'Invalid deployment id'
@ -138,28 +123,63 @@ export const deploymentInsertSchema = createInsertSchema(deployments, {
message: 'Invalid deployment hash'
}),
pricingPlans: pricingPlanListSchema
// coupons: z.array(couponSchema)
})
.omit({
originUrl: true
})
.extend({
user: z
.lazy(() => userSelectSchema)
.optional()
.openapi('User', { type: 'object' }),
team: z
.lazy(() => teamSelectSchema)
.optional()
.openapi('Team', { type: 'object' }),
// TODO: Circular references make this schema less than ideal
project: z.object({}).optional().openapi('Project', { type: 'object' })
})
.strip()
.openapi('Deployment')
export const deploymentInsertSchema = createInsertSchema(deployments, {
projectId: (schema) =>
schema.refine((id) => validators.projectId(id), {
message: 'Invalid project id'
}),
_url: (schema) => schema.url(),
originUrl: (schema) => schema.url(),
pricingPlans: pricingPlanListSchema
// TODOp
// coupons: z.array(couponSchema).optional()
})
.omit({ id: true, createdAt: true, updatedAt: true })
.omit({
id: true,
createdAt: true,
updatedAt: true,
hash: true,
userId: true,
teamId: true
})
.strict()
export const deploymentUpdateSchema = createUpdateSchema(deployments)
.pick({
enabled: true,
published: true,
version: true,
description: true
})
.strict()
// TODO: add admin select schema which includes all fields?
export const deploymentPublishSchema = createUpdateSchema(deployments, {
version: z.string().nonempty()
})
.pick({
version: true
})
.strict()

Wyświetl plik

@ -18,8 +18,7 @@ import {
type StripePriceIdMap,
stripePriceIdMapSchema,
type StripeProductIdMap,
stripeProductIdMapSchema,
type Webhook
stripeProductIdMapSchema
} from './schemas'
import { teams, teamSelectSchema } from './team'
import { users, userSelectSchema } from './user'
@ -77,13 +76,11 @@ export const projects = pgTable(
// All deployments share the same underlying proxy secret
_secret: text().notNull(),
// Auth token used to access the saasify API on behalf of this project
// Auth token used to access the platform API on behalf of this project
_providerToken: text().notNull(),
// TODO: Full-text search
_text: text().default('').notNull(),
_webhooks: jsonb().$type<Webhook[]>().default([]).notNull(),
// _text: text().default('').notNull(),
// Stripe coupons associated with this project, mapping from unique coupon
// object hash to stripe coupon id.
@ -184,8 +181,7 @@ export const projectSelectSchema = createSelectSchema(projects, {
.omit({
_secret: true,
_providerToken: true,
_text: true,
_webhooks: true,
// _text: true,
_stripeProductIdMap: true,
_stripePriceIdMap: true,
_stripeMeterIdMap: true,
@ -227,8 +223,7 @@ export const projectInsertSchema = createInsertSchema(projects, {
})
})
.pick({
name: true,
teamId: true
name: true
})
.strict()

Wyświetl plik

@ -18,7 +18,7 @@ export async function upsertConsumer(
deploymentId,
consumerId
}: {
plan: string
plan?: string
deploymentId?: string
consumerId?: string
}

Wyświetl plik

@ -19,7 +19,7 @@ export const team = createMiddleware<AuthenticatedEnv>(
eq(schema.teamMembers.userId, user.id)
)
})
assert(teamMember, 401, 'Unauthorized')
assert(teamMember, 403, 'Unauthorized')
await aclTeamMember(ctx, { teamMember })

Wyświetl plik

@ -8,8 +8,6 @@ import { assert } from './utils'
/**
* Attempts to find the Deployment matching the given identifier.
*
* If the Deployment is not found, throw an HttpError.
*/
export async function tryGetDeployment(
ctx: AuthenticatedContext,
@ -21,7 +19,7 @@ export async function tryGetDeployment(
project?: true
}
} = {}
): Promise<RawDeployment> {
): Promise<RawDeployment | undefined> {
const user = await ensureAuthUser(ctx)
const teamMember = ctx.get('teamMember')

Wyświetl plik

@ -179,6 +179,9 @@ importers:
restore-cursor:
specifier: 'catalog:'
version: 5.1.0
semver:
specifier: ^7.7.2
version: 7.7.2
stripe:
specifier: ^18.1.0
version: 18.1.0(@types/node@22.15.18)
@ -195,6 +198,9 @@ importers:
'@types/jsonwebtoken':
specifier: ^9.0.9
version: 9.0.9
'@types/semver':
specifier: ^7.7.0
version: 7.7.0
drizzle-kit:
specifier: ^0.31.1
version: 0.31.1
@ -1133,6 +1139,9 @@ packages:
'@types/pg@8.6.1':
resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==}
'@types/semver@7.7.0':
resolution: {integrity: sha512-k107IF4+Xr7UHjwDc7Cfd6PRQfbdkiRabXGRjo07b4WyPahFBZCZ1sE+BNxYIJPPg73UkfOsVOLwqVc/6ETrIA==}
'@types/shimmer@1.2.0':
resolution: {integrity: sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg==}
@ -4278,6 +4287,8 @@ snapshots:
pg-protocol: 1.10.0
pg-types: 2.2.0
'@types/semver@7.7.0': {}
'@types/shimmer@1.2.0': {}
'@types/tedious@4.0.14':