From 2b1e50db33df935b651290cc857c1b640bed0407 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 7 Feb 2025 13:14:55 -0600 Subject: [PATCH] use CreatedTemplate[Filter|Global*] as canonical template extensions in scaffolder plugin Signed-off-by: Matt Benson --- .changeset/shiny-ways-develop.md | 5 + plugins/scaffolder-backend/package.json | 3 +- .../src/ScaffolderPlugin.ts | 64 +++--- .../src/lib/templating/filters.ts | 2 +- .../actions/builtin/fetch/template.ts | 9 +- .../actions/builtin/fetch/templateFile.ts | 7 +- .../tasks/NunjucksWorkflowRunner.ts | 11 +- .../scaffolder-backend/src/service/router.ts | 19 +- .../src/util/templating.test.ts | 194 +++++++++++++++++ .../scaffolder-backend/src/util/templating.ts | 204 ++++++++++++++++++ yarn.lock | 1 + 11 files changed, 463 insertions(+), 56 deletions(-) create mode 100644 .changeset/shiny-ways-develop.md create mode 100644 plugins/scaffolder-backend/src/util/templating.test.ts create mode 100644 plugins/scaffolder-backend/src/util/templating.ts diff --git a/.changeset/shiny-ways-develop.md b/.changeset/shiny-ways-develop.md new file mode 100644 index 0000000000..5721ed88b0 --- /dev/null +++ b/.changeset/shiny-ways-develop.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +use CreatedTemplate[Filter|Global*] as canonical template extensions in scaffolder plugin diff --git a/plugins/scaffolder-backend/package.json b/plugins/scaffolder-backend/package.json index a220df27ec..b5f437a6af 100644 --- a/plugins/scaffolder-backend/package.json +++ b/plugins/scaffolder-backend/package.json @@ -113,7 +113,8 @@ "winston-transport": "^4.7.0", "yaml": "^2.0.0", "zen-observable": "^0.10.0", - "zod": "^3.22.4" + "zod": "^3.22.4", + "zod-to-json-schema": "^3.20.4" }, "devDependencies": { "@backstage/backend-app-api": "workspace:^", diff --git a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts index db1e38c89a..e8f6630a85 100644 --- a/plugins/scaffolder-backend/src/ScaffolderPlugin.ts +++ b/plugins/scaffolder-backend/src/ScaffolderPlugin.ts @@ -21,14 +21,14 @@ import { import { ScmIntegrations } from '@backstage/integration'; import { catalogServiceRef } from '@backstage/plugin-catalog-node/alpha'; import { eventsServiceRef } from '@backstage/plugin-events-node'; -import { - TaskBroker, - TemplateAction, - TemplateFilter, - TemplateGlobal, -} from '@backstage/plugin-scaffolder-node'; +import { TaskBroker, TemplateAction } from '@backstage/plugin-scaffolder-node'; import { AutocompleteHandler, + CreatedTemplateFilter, + CreatedTemplateGlobal, + createTemplateFilter, + createTemplateGlobalFunction, + createTemplateGlobalValue, scaffolderActionsExtensionPoint, scaffolderAutocompleteExtensionPoint, scaffolderTaskBrokerExtensionPoint, @@ -52,6 +52,7 @@ import { } from './scaffolder'; import { createRouter } from './service/router'; import { loggerToWinstonLogger } from './util/loggerToWinstonLogger'; +import { templateFilterImpls, templateGlobals } from './util/templating'; /** * Scaffolder plugin @@ -78,30 +79,31 @@ export const scaffolderPlugin = createBackendPlugin({ }, }); - const additionalTemplateFilters: Record = {}; - const additionalTemplateGlobals: Record = {}; + const additionalTemplateFilters: CreatedTemplateFilter[] = []; + const additionalTemplateGlobals: CreatedTemplateGlobal[] = []; + env.registerExtensionPoint(scaffolderTemplatingExtensionPoint, { addTemplateFilters(newFilters) { - Object.assign( - additionalTemplateFilters, - Array.isArray(newFilters) - ? Object.fromEntries( - newFilters.map(tf => [tf.id, tf.filter as TemplateFilter]), - ) - : newFilters, + additionalTemplateFilters.push( + ...(Array.isArray(newFilters) + ? newFilters + : Object.entries(newFilters).map(([id, filter]) => + createTemplateFilter({ + id, + filter, + }), + )), ); }, addTemplateGlobals(newGlobals) { - Object.assign( - additionalTemplateGlobals, - Array.isArray(newGlobals) - ? Object.fromEntries( - newGlobals.map(g => [ - g.id, - ('value' in g ? g.value : g.fn) as TemplateGlobal, - ]), - ) - : newGlobals, + additionalTemplateGlobals.push( + ...(Array.isArray(newGlobals) + ? newGlobals + : Object.entries(newGlobals).map(([id, global]) => + typeof global === 'function' + ? createTemplateGlobalFunction({ id, fn: global }) + : createTemplateGlobalValue({ id, value: global }), + )), ); }, }); @@ -154,6 +156,12 @@ export const scaffolderPlugin = createBackendPlugin({ const log = loggerToWinstonLogger(logger); const integrations = ScmIntegrations.fromConfig(config); + const templateExtensions = { + additionalTemplateFilters: templateFilterImpls( + additionalTemplateFilters, + ), + additionalTemplateGlobals: templateGlobals(additionalTemplateGlobals), + }; const actions = [ // actions provided from other modules ...addedActions, @@ -170,14 +178,12 @@ export const scaffolderPlugin = createBackendPlugin({ createFetchTemplateAction({ integrations, reader, - additionalTemplateFilters, - additionalTemplateGlobals, + ...templateExtensions, }), createFetchTemplateFileAction({ integrations, reader, - additionalTemplateFilters, - additionalTemplateGlobals, + ...templateExtensions, }), createDebugLogAction(), createWaitAction(), diff --git a/plugins/scaffolder-backend/src/lib/templating/filters.ts b/plugins/scaffolder-backend/src/lib/templating/filters.ts index a8101a64d4..bc3f95b18c 100644 --- a/plugins/scaffolder-backend/src/lib/templating/filters.ts +++ b/plugins/scaffolder-backend/src/lib/templating/filters.ts @@ -22,7 +22,7 @@ import { } from '@backstage/plugin-scaffolder-node'; import get from 'lodash/get'; -export const createDefaultFilters = ({ +export default ({ integrations, }: { integrations: ScmIntegrations; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts index 2025202c22..7b11474d18 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -29,8 +29,9 @@ import globby from 'globby'; import fs from 'fs-extra'; import { isBinaryFile } from 'isbinaryfile'; import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; -import { createDefaultFilters } from '../../../../lib/templating/filters'; +import createDefaultFilters from '../../../../lib/templating/filters'; import { examples } from './template.examples'; +import { templateFilterImpls } from '../../../../util/templating'; /** * Downloads a skeleton, templates variables into file and directory names and content. @@ -52,7 +53,9 @@ export function createFetchTemplateAction(options: { additionalTemplateGlobals, } = options; - const defaultTemplateFilters = createDefaultFilters({ integrations }); + const defaultTemplateFilters = templateFilterImpls( + createDefaultFilters({ integrations }), + ); return createTemplateAction<{ url: string; @@ -243,7 +246,7 @@ export function createFetchTemplateAction(options: { cookiecutterCompat: ctx.input.cookiecutterCompat, templateFilters: { ...defaultTemplateFilters, - ...additionalTemplateFilters, + ...(additionalTemplateFilters ?? {}), }, templateGlobals: additionalTemplateGlobals, nunjucksConfigs: { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts index 5734156148..8a07896dbf 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts @@ -25,9 +25,10 @@ import { TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; -import { createDefaultFilters } from '../../../../lib/templating/filters'; +import createDefaultFilters from '../../../../lib/templating/filters'; import path from 'path'; import fs from 'fs-extra'; +import { templateFilterImpls } from '../../../../util/templating'; /** * Downloads a single file and templates variables into file. @@ -48,7 +49,9 @@ export function createFetchTemplateFileAction(options: { additionalTemplateGlobals, } = options; - const defaultTemplateFilters = createDefaultFilters({ integrations }); + const defaultTemplateFilters = templateFilterImpls( + createDefaultFilters({ integrations }), + ); return createTemplateAction<{ url: string; diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 906f6d1857..f2979cea43 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -55,11 +55,12 @@ import { TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; -import { createDefaultFilters } from '../../lib/templating/filters'; +import createDefaultFilters from '../../lib/templating/filters'; import { scaffolderActionRules } from '../../service/rules'; import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; import { BackstageLoggerTransport, WinstonLogger } from './logger'; import { loggerToWinstonLogger } from '../../util/loggerToWinstonLogger'; +import { templateFilterImpls } from '../../util/templating'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -151,9 +152,11 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { private readonly defaultTemplateFilters: Record; constructor(private readonly options: NunjucksWorkflowRunnerOptions) { - this.defaultTemplateFilters = createDefaultFilters({ - integrations: this.options.integrations, - }); + this.defaultTemplateFilters = templateFilterImpls( + createDefaultFilters({ + integrations: this.options.integrations, + }), + ); } private readonly tracker = scaffoldingTracker(); diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index ac6cfd38d0..3c6db3dd03 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -110,6 +110,7 @@ import { } from './helpers'; import { scaffolderActionRules, scaffolderTemplateRules } from './rules'; import { HostDiscovery } from '@backstage/backend-defaults/discovery'; +import { templateFilterImpls, templateGlobals } from '../util/templating'; /** * @@ -368,22 +369,8 @@ export async function createRouter( const actionRegistry = new TemplateActionRegistry(); const templateExtensions = { - additionalTemplateFilters: Array.isArray(additionalTemplateFilters) - ? Object.fromEntries( - additionalTemplateFilters.map(f => [ - f.id, - f.filter as TemplateFilter, - ]), - ) - : additionalTemplateFilters, - additionalTemplateGlobals: Array.isArray(additionalTemplateGlobals) - ? Object.fromEntries( - additionalTemplateGlobals.map(g => [ - g.id, - ('value' in g ? g.value : g.fn) as TemplateGlobal, - ]), - ) - : additionalTemplateGlobals, + additionalTemplateFilters: templateFilterImpls(additionalTemplateFilters), + additionalTemplateGlobals: templateGlobals(additionalTemplateGlobals), }; const workers: TaskWorker[] = []; diff --git a/plugins/scaffolder-backend/src/util/templating.test.ts b/plugins/scaffolder-backend/src/util/templating.test.ts new file mode 100644 index 0000000000..f5d6431f28 --- /dev/null +++ b/plugins/scaffolder-backend/src/util/templating.test.ts @@ -0,0 +1,194 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + TemplateFilter, + CreatedTemplateGlobal, + createTemplateFilter, + createTemplateGlobalFunction, + createTemplateGlobalValue, +} from '@backstage/plugin-scaffolder-node/alpha'; +import { + templateFilterImpls, + templateFilterMetadata, + templateGlobalFunctionMetadata, + templateGlobals, + templateGlobalValueMetadata, +} from './templating'; +import { JsonValue } from '@backstage/types'; +import builtInFilters from '../lib/templating/filters'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; + +describe('templating utilities', () => { + describe('built-in filters', () => { + const integrations = ScmIntegrations.fromConfig(new ConfigReader({})); + const filters = builtInFilters({ integrations }); + it('generates equivalent filter metadata', () => { + const metadata = templateFilterMetadata(filters); + expect(metadata).toMatchObject(templateFilterMetadata(filters)); + }); + }); + describe('api filters', () => { + const filters = [ + createTemplateFilter({ + id: 'nop', + description: 'nop', + schema: z => z.function().args(z.any()).returns(z.any()), + filter: (x: JsonValue) => x, + }), + createTemplateFilter({ + id: 'nul', + filter: (_: JsonValue) => null, + }), + createTemplateFilter({ + id: 'repeat', + schema: z => + z + .function() + .args( + z.string().describe('input'), + z.number().describe('times'), + z.string().describe('separator').optional(), + ) + .returns(z.string()), + filter: (input: string, times: number, separator?: string) => + Array(times) + .fill(input) + .join(separator ?? ''), + }), + ]; + it('extracts filter implementations', () => { + const impls = templateFilterImpls(filters); + expect(impls).toHaveProperty('nop'); + expect(impls.nop(42)).toBe(42); + expect(impls).toHaveProperty('nul'); + expect(impls.nul('anything')).toBeNull(); + expect(impls).toHaveProperty('repeat'); + expect(impls.repeat('foo', 3)).toBe('foofoofoo'); + expect(impls.repeat('foo', 3, '-')).toBe('foo-foo-foo'); + }); + it('extracts filter metadata', () => { + const metadata = templateFilterMetadata(filters); + expect(metadata).toHaveProperty('nop'); + expect(metadata.nop).toHaveProperty('description', 'nop'); + expect(metadata.nop).toHaveProperty( + 'schema', + expect.objectContaining({ + input: expect.not.objectContaining({ type: expect.anything() }), + output: expect.not.objectContaining({ type: expect.anything() }), + }), + ); + expect(metadata.nop.schema).not.toHaveProperty('arguments'); + expect(metadata).toHaveProperty('nul', {}); + expect(metadata).toHaveProperty('repeat'); + expect(metadata.repeat).not.toHaveProperty('description'); + expect(metadata.repeat).toHaveProperty( + 'schema', + expect.objectContaining({ + input: expect.objectContaining({ type: 'string' }), + arguments: expect.arrayContaining([ + expect.objectContaining({ + type: 'number', + }), + expect.objectContaining({ + anyOf: expect.arrayContaining([ + expect.objectContaining({ not: {} }), + expect.objectContaining({ type: 'string' }), + ]), + }), + ]), + output: expect.objectContaining({ type: 'string' }), + }), + ); + }); + }); + describe('legacy filters', () => { + const filters = { + nop: x => x, + nul: _ => null, + } as Record; + it('extracts filter implementations', () => { + const impls = templateFilterImpls(filters); + expect(impls).toHaveProperty('nop'); + expect(impls.nop(42)).toBe(42); + expect(impls).toHaveProperty('nul'); + expect(impls.nul('anything')).toBeNull(); + }); + it('extracts filter metadata', () => { + const metadata = templateFilterMetadata(filters); + expect(metadata).toHaveProperty('nop', {}); + expect(metadata).toHaveProperty('nul', {}); + }); + }); + + const documentedGlobals: CreatedTemplateGlobal[] = [ + createTemplateGlobalFunction({ + id: 'foo', + description: 'foo something', + fn: (x: any) => `${x}_FOO`, + }), + createTemplateGlobalValue({ + id: 'bar', + description: 'bar value', + value: 'bar', + }), + createTemplateGlobalFunction({ + id: 'respond', + schema: z => + z.function().args(z.string().describe('prompt')).returns(z.string()), + fn: (prompt: string) => + prompt === 'knock knock' ? "who's there?" : "nobody's home", + }), + ]; + it('extracts global function metadata', () => { + const metadata = templateGlobalFunctionMetadata(documentedGlobals); + expect(metadata).toHaveProperty('foo'); + expect(metadata.foo).toHaveProperty('description', 'foo something'); + expect(metadata).not.toHaveProperty('bar'); + expect(metadata).toHaveProperty( + 'respond', + expect.objectContaining({ + schema: expect.objectContaining({ + arguments: expect.arrayContaining([ + expect.objectContaining({ type: 'string' }), + ]), + output: expect.objectContaining({ type: 'string' }), + }), + }), + ); + }); + it('extracts global value metadata', () => { + const metadata = templateGlobalValueMetadata(documentedGlobals); + expect(metadata).not.toHaveProperty('foo'); + expect(metadata).toHaveProperty('bar'); + expect(metadata.bar).toHaveProperty('description', 'bar value'); + expect(metadata).not.toHaveProperty('respond'); + }); + const globals = templateGlobals(documentedGlobals); + it('extracts documented globals', () => { + expect(globals).toHaveProperty('foo'); + expect((globals.foo as (x: any) => string)('something')).toBe( + 'something_FOO', + ); + expect(globals).toHaveProperty('bar', 'bar'); + expect(globals).toHaveProperty('respond'); + expect(typeof globals.respond).toBe('function'); + expect((globals.respond as Function)('knock knock')).toBe("who's there?"); + }); + it('extracts backward-compatible/undocumented globals', () => { + expect(templateGlobals(globals)).toBe(globals); + }); +}); diff --git a/plugins/scaffolder-backend/src/util/templating.ts b/plugins/scaffolder-backend/src/util/templating.ts new file mode 100644 index 0000000000..71780ec104 --- /dev/null +++ b/plugins/scaffolder-backend/src/util/templating.ts @@ -0,0 +1,204 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + TemplateFilter, + TemplateGlobal, +} from '@backstage/plugin-scaffolder-node'; +import { + CreatedTemplateFilter, + CreatedTemplateGlobal, + CreatedTemplateGlobalFunction, + CreatedTemplateGlobalValue, + TemplateGlobalFunctionSchema, +} from '@backstage/plugin-scaffolder-node/alpha'; +import { JsonValue } from '@backstage/types'; +import { Schema } from 'jsonschema'; +import { + filter, + fromPairs, + isEmpty, + keyBy, + mapValues, + negate, + pick, + pickBy, + toPairs, + wrap, +} from 'lodash'; +import { z, ZodType } from 'zod'; +import zodToJsonSchema from 'zod-to-json-schema'; + +export function templateFilterImpls( + filters?: Record | CreatedTemplateFilter[], +): Record { + if (!filters) { + return {}; + } + if (Array.isArray(filters)) { + return mapValues(keyBy(filters, 'id'), f => f.filter as TemplateFilter); + } + return filters; +} + +type ExportFunctionSchema = { + arguments?: Schema[]; + output?: Schema; +}; + +type ExportFilterSchema = { + input?: Schema; +} & ExportFunctionSchema; + +function zodFunctionToJsonSchema( + t: ReturnType>, +): ExportFunctionSchema { + const args = (t.parameters().items as ZodType[]).map( + zt => zodToJsonSchema(zt) as Schema, + ); + const output = wrap(t.returnType(), rt => + rt._unknown ? undefined : (zodToJsonSchema(rt) as Schema), + )(); + return pickBy({ output, arguments: args }, negate(isEmpty)); +} + +function toExportFilterSchema( + fnSchema: ExportFunctionSchema, +): ExportFilterSchema { + if (fnSchema.arguments?.length) { + const [input, ...rest] = fnSchema.arguments; + const { output } = fnSchema; + return { + input, + ...(rest.length ? { arguments: rest } : {}), + ...(output ? { output } : {}), + }; + } + return fnSchema; +} + +type ExportFilter = Pick< + CreatedTemplateFilter, + 'description' | 'examples' +> & { + schema?: ExportFilterSchema; +}; + +export function templateFilterMetadata( + filters?: Record | CreatedTemplateFilter[], +): Record { + if (!filters) { + return {}; + } + if (Array.isArray(filters)) { + return mapValues( + keyBy(filters, 'id'), + >(f: F): ExportFilter => { + const schema = f.schema + ? toExportFilterSchema(zodFunctionToJsonSchema(f.schema(z))) + : undefined; + return { + ...pick(f, 'description', 'examples'), + ...pickBy({ schema }), + }; + }, + ); + } + return mapValues(filters, _ => ({})); +} + +function isGlobalFunctionInfo( + global: CreatedTemplateGlobal, +): global is CreatedTemplateGlobalFunction< + TemplateGlobalFunctionSchema | undefined, + any +> { + return 'fn' in global; +} + +type GlobalRecordRow = [string, TemplateGlobal]; + +export function templateGlobalFunctionMetadata( + globals?: Record | CreatedTemplateGlobal[], +): Record< + string, + Pick, 'description' | 'examples'> & { + schema?: ExportFunctionSchema; + } +> { + if (!globals) { + return {}; + } + if (Array.isArray(globals)) { + return mapValues(keyBy(filter(globals, isGlobalFunctionInfo), 'id'), v => { + const schema = v.schema + ? zodFunctionToJsonSchema(v.schema(z)) + : undefined; + + return { + ...pick(v, 'description', 'examples'), + ...pickBy({ schema }), + }; + }); + } + const rows = toPairs(globals) as GlobalRecordRow[]; + const fns = rows.filter(([_, g]) => typeof g === 'function') as [ + string, + Exclude, + ][]; + return fromPairs(fns.map(([k, _]) => [k, {}])); +} + +export function templateGlobalValueMetadata( + globals?: Record | CreatedTemplateGlobal[], +): Record> { + if (!globals) { + return {}; + } + if (Array.isArray(globals)) { + return mapValues( + keyBy( + filter( + globals, + negate(isGlobalFunctionInfo), + ) as CreatedTemplateGlobalValue[], + 'id', + ), + v => pick(v, 'value', 'description'), + ); + } + const rows = toPairs(globals) as GlobalRecordRow[]; + const vals = rows.filter(([_, g]) => typeof g !== 'function') as [ + string, + JsonValue, + ][]; + return fromPairs(vals.map(([k, value]) => [k, { value }])); +} + +export function templateGlobals( + globals?: Record | CreatedTemplateGlobal[], +): Record { + if (!globals) { + return {}; + } + if (!Array.isArray(globals)) { + return globals; + } + return mapValues(keyBy(globals, 'id'), v => + isGlobalFunctionInfo(v) + ? (v.fn as TemplateGlobal) + : (v as CreatedTemplateGlobalValue).value, + ); +} diff --git a/yarn.lock b/yarn.lock index e5260d2a63..ae865354b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7738,6 +7738,7 @@ __metadata: yaml: ^2.0.0 zen-observable: ^0.10.0 zod: ^3.22.4 + zod-to-json-schema: ^3.20.4 languageName: unknown linkType: soft