From 8355dd9b92fa675fa5544a205acd004be586e83c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Fri, 7 Feb 2025 14:33:00 +0100 Subject: [PATCH] cli/new: merge + refactor preparation step Signed-off-by: Patrik Oldsberg --- packages/cli/src/lib/new/createNewPackage.ts | 11 +- .../lib/new/loader/NewTemplateLoader.test.ts | 188 ------------------ .../src/lib/new/loader/NewTemplateLoader.ts | 122 ------------ .../loadConfig.ts} | 2 +- .../lib/new/preparation/loadTemplate.test.ts | 113 +++++++++++ .../src/lib/new/preparation/loadTemplate.ts | 85 ++++++++ .../selectTemplateInteractively.test.ts | 85 ++++++++ .../selectTemplateInteractively.ts | 50 +++++ 8 files changed, 340 insertions(+), 316 deletions(-) delete mode 100644 packages/cli/src/lib/new/loader/NewTemplateLoader.test.ts delete mode 100644 packages/cli/src/lib/new/loader/NewTemplateLoader.ts rename packages/cli/src/lib/new/{config/loadNewConfig.ts => preparation/loadConfig.ts} (97%) create mode 100644 packages/cli/src/lib/new/preparation/loadTemplate.test.ts create mode 100644 packages/cli/src/lib/new/preparation/loadTemplate.ts create mode 100644 packages/cli/src/lib/new/preparation/selectTemplateInteractively.test.ts create mode 100644 packages/cli/src/lib/new/preparation/selectTemplateInteractively.ts diff --git a/packages/cli/src/lib/new/createNewPackage.ts b/packages/cli/src/lib/new/createNewPackage.ts index b631d458a8..dd8b71a68f 100644 --- a/packages/cli/src/lib/new/createNewPackage.ts +++ b/packages/cli/src/lib/new/createNewPackage.ts @@ -15,9 +15,10 @@ */ import { collectTemplateParams } from './collection/collectTemplateParams'; -import { loadNewConfig } from './config/loadNewConfig'; +import { loadConfig } from './preparation/loadConfig'; import { executeNewTemplate } from './execution/executeTemplate'; -import { NewTemplateLoader } from './loader/NewTemplateLoader'; +import { selectTemplateInteractively } from './preparation/selectTemplateInteractively'; +import { loadTemplate } from './preparation/loadTemplate'; export type CreateNewPackageOptions = { preselectedTemplateId?: string; @@ -32,13 +33,13 @@ export type CreateNewPackageOptions = { }; export async function createNewPackage(options: CreateNewPackageOptions) { - const config = await loadNewConfig(); + const config = await loadConfig(); - const selectedTemplate = await NewTemplateLoader.selectTemplateInteractively( + const selectedTemplate = await selectTemplateInteractively( config, options.preselectedTemplateId, ); - const template = await NewTemplateLoader.loadTemplate(selectedTemplate); + const template = await loadTemplate(selectedTemplate); const params = await collectTemplateParams({ config, diff --git a/packages/cli/src/lib/new/loader/NewTemplateLoader.test.ts b/packages/cli/src/lib/new/loader/NewTemplateLoader.test.ts deleted file mode 100644 index dad06a3ccd..0000000000 --- a/packages/cli/src/lib/new/loader/NewTemplateLoader.test.ts +++ /dev/null @@ -1,188 +0,0 @@ -/* - * 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 { NewTemplateLoader } from './NewTemplateLoader'; -import { NewConfig } from '../config/types'; -import inquirer from 'inquirer'; -import { withLogCollector } from '@backstage/test-utils'; -import { createMockDirectory } from '@backstage/backend-test-utils'; - -describe('NewTemplateLoader.selectTemplateInteractively', () => { - const mockConfig: NewConfig = { - isUsingDefaultTemplates: false, - templatePointers: [ - { id: 'template1', target: 'path/to/template1' }, - { id: 'template2', target: 'path/to/template2' }, - ], - globals: {}, - }; - - beforeEach(() => { - jest.resetAllMocks(); - }); - - it('should select a template interactively', async () => { - jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ id: 'template1' }); - - const result = await NewTemplateLoader.selectTemplateInteractively( - mockConfig, - ); - - expect(result).toEqual({ id: 'template1', target: 'path/to/template1' }); - }); - - it('should error if interactive selections is not found', async () => { - jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ id: 'nonexistent' }); - - await expect( - NewTemplateLoader.selectTemplateInteractively(mockConfig), - ).rejects.toThrow("Template 'nonexistent' not found"); - }); - - it('should use preselected template id', async () => { - const result = await NewTemplateLoader.selectTemplateInteractively( - mockConfig, - 'template2', - ); - - expect(result).toEqual({ id: 'template2', target: 'path/to/template2' }); - }); - - it('should throw an error if template is not found', async () => { - await expect( - NewTemplateLoader.selectTemplateInteractively(mockConfig, 'nonexistent'), - ).rejects.toThrow("Template 'nonexistent' not found"); - }); - - it('should rewrite plugin to frontend-plugin if default templates are used', async () => { - await expect( - NewTemplateLoader.selectTemplateInteractively(mockConfig, 'plugin'), - ).rejects.toThrow("Template 'plugin' not found"); - - const logs = await withLogCollector(async () => { - await expect( - NewTemplateLoader.selectTemplateInteractively( - { ...mockConfig, isUsingDefaultTemplates: true }, - 'plugin', - ), - ).rejects.toThrow("Template 'frontend-plugin' not found"); - }); - expect(logs).toEqual({ - log: [], - warn: [ - "DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead", - ], - error: [], - }); - }); -}); - -describe('NewTemplateLoader.loadTemplate', () => { - describe('NewTemplateLoader.loadTemplate', () => { - it('should load a valid template', async () => { - const mockDir = createMockDirectory({ - content: { - 'path/to/template1.yaml': ` -template: template1 -targetPath: plugins -`, - 'path/to/template1/hello.txt': 'hello world', - }, - }); - - const result = await NewTemplateLoader.loadTemplate({ - id: 'template1', - target: mockDir.resolve('path/to/template1.yaml'), - }); - - expect(result).toEqual({ - id: 'template1', - templatePath: mockDir.resolve('path/to/template1'), - targetPath: 'plugins', - }); - }); - - it('should throw an error if template file does not exist', async () => { - const mockDir = createMockDirectory(); - - await expect( - NewTemplateLoader.loadTemplate({ - id: 'template1', - target: mockDir.resolve('path/to/template1.yaml'), - }), - ).rejects.toThrow( - /^Failed to load template definition from '.*'; caused by Error: ENOENT/, - ); - }); - - it('should throw an error if template definition is invalid', async () => { - const mockDir = createMockDirectory({ - content: { - 'path/to/template1.yaml': `invalid: definition`, - }, - }); - - await expect( - NewTemplateLoader.loadTemplate({ - id: 'template1', - target: mockDir.resolve('path/to/template1.yaml'), - }), - ).rejects.toThrow( - /Invalid template definition at '.*'; caused by Validation error/, - ); - }); - - it('should throw an error if target is a remote URL', async () => { - await expect( - NewTemplateLoader.loadTemplate({ - id: 'template1', - target: 'http://example.com', - }), - ).rejects.toThrow('Remote templates are not supported yet'); - }); - - it('should throw an error if target directory does not exist', async () => { - await expect( - NewTemplateLoader.loadTemplate({ - id: 'template1', - target: 'http://example.com', - }), - ).rejects.toThrow('Remote templates are not supported yet'); - }); - - it('should throw an error if template directory does not exist', async () => { - const mockDir = createMockDirectory({ - content: { - 'path/to/template1.yaml': ` - template: template1 - targetPath: plugins - `, - }, - }); - - await expect( - NewTemplateLoader.loadTemplate({ - id: 'template1', - target: mockDir.resolve('path/to/template1.yaml'), - }), - ).rejects.toThrow( - `Failed to load template contents from '${mockDir.resolve( - 'path/to/template1', - )}'`, - ); - }); - }); -}); diff --git a/packages/cli/src/lib/new/loader/NewTemplateLoader.ts b/packages/cli/src/lib/new/loader/NewTemplateLoader.ts deleted file mode 100644 index c7deb9114f..0000000000 --- a/packages/cli/src/lib/new/loader/NewTemplateLoader.ts +++ /dev/null @@ -1,122 +0,0 @@ -/* - * 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 { z } from 'zod'; -import fs from 'fs-extra'; -import inquirer from 'inquirer'; -import { resolve as resolvePath } from 'path'; -import { dirname } from 'node:path'; -import { parse as parseYaml } from 'yaml'; -import { paths } from '../../paths'; -import { NewConfig, NewTemplatePointer } from '../types'; -import { NewTemplate } from '../types'; -import { ForwardedError } from '@backstage/errors'; -import { fromZodError } from 'zod-validation-error'; - -const templateDefinitionSchema = z - .object({ - description: z.string().optional(), - template: z.string(), - targetPath: z.string(), - plugin: z.boolean().optional(), - backendModulePrefix: z.boolean().optional(), - suffix: z.string().optional(), - prompts: z - .array( - z.union([ - z.string(), - z.object({ - id: z.string(), - prompt: z.string(), - validate: z.string().optional(), - default: z.union([z.string(), z.boolean(), z.number()]).optional(), - }), - ]), - ) - .optional(), - additionalActions: z.array(z.string()).optional(), - }) - .strict(); - -export class NewTemplateLoader { - static async selectTemplateInteractively( - config: NewConfig, - preselectedTemplateId?: string, - ): Promise { - let selectedId = preselectedTemplateId; - - if (config.isUsingDefaultTemplates && selectedId === 'plugin') { - console.warn( - `DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead`, - ); - selectedId = 'frontend-plugin'; - } - - if (!selectedId) { - const answers = await inquirer.prompt<{ id: string }>([ - { - type: 'list', - name: 'id', - message: 'What do you want to create?', - choices: config.templatePointers.map(t => t.id), - }, - ]); - selectedId = answers.id; - } - - const template = config.templatePointers.find(t => t.id === selectedId); - if (!template) { - throw new Error(`Template '${selectedId}' not found`); - } - return template; - } - - static async loadTemplate({ - id, - target, - }: NewTemplatePointer): Promise { - if (target.match(/https?:\/\//)) { - throw new Error('Remote templates are not supported yet'); - } - const templateContent = await fs - .readFile(paths.resolveTargetRoot(target), 'utf-8') - .catch(error => { - throw new ForwardedError( - `Failed to load template definition from '${target}'`, - error, - ); - }); - const rawTemplate = parseYaml(templateContent); - - const parsed = templateDefinitionSchema.safeParse(rawTemplate); - if (!parsed.success) { - throw new ForwardedError( - `Invalid template definition at '${target}'`, - fromZodError(parsed.error), - ); - } - - const { template, ...templateData } = parsed.data; - - const templatePath = resolvePath(dirname(target), template); - if (!fs.existsSync(templatePath)) { - throw new Error( - `Failed to load template contents from '${templatePath}'`, - ); - } - return { id, templatePath, ...templateData }; - } -} diff --git a/packages/cli/src/lib/new/config/loadNewConfig.ts b/packages/cli/src/lib/new/preparation/loadConfig.ts similarity index 97% rename from packages/cli/src/lib/new/config/loadNewConfig.ts rename to packages/cli/src/lib/new/preparation/loadConfig.ts index 1387f5b45c..9c3a600b29 100644 --- a/packages/cli/src/lib/new/config/loadNewConfig.ts +++ b/packages/cli/src/lib/new/preparation/loadConfig.ts @@ -47,7 +47,7 @@ const pkgJsonWithNewConfigSchema = z.object({ .optional(), }); -export async function loadNewConfig(): Promise { +export async function loadConfig(): Promise { const pkgPath = paths.resolveTargetRoot('package.json'); const pkgJson = await fs.readJson(pkgPath); diff --git a/packages/cli/src/lib/new/preparation/loadTemplate.test.ts b/packages/cli/src/lib/new/preparation/loadTemplate.test.ts new file mode 100644 index 0000000000..f8188f2fc9 --- /dev/null +++ b/packages/cli/src/lib/new/preparation/loadTemplate.test.ts @@ -0,0 +1,113 @@ +/* + * 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 { createMockDirectory } from '@backstage/backend-test-utils'; +import { loadTemplate } from './loadTemplate'; + +describe('loadTemplate', () => { + it('should load a valid template', async () => { + const mockDir = createMockDirectory({ + content: { + 'path/to/template1.yaml': ` +template: template1 +targetPath: plugins +`, + 'path/to/template1/hello.txt': 'hello world', + }, + }); + + const result = await loadTemplate({ + id: 'template1', + target: mockDir.resolve('path/to/template1.yaml'), + }); + + expect(result).toEqual({ + id: 'template1', + templatePath: mockDir.resolve('path/to/template1'), + targetPath: 'plugins', + }); + }); + + it('should throw an error if template file does not exist', async () => { + const mockDir = createMockDirectory(); + + await expect( + loadTemplate({ + id: 'template1', + target: mockDir.resolve('path/to/template1.yaml'), + }), + ).rejects.toThrow( + /^Failed to load template definition from '.*'; caused by Error: ENOENT/, + ); + }); + + it('should throw an error if template definition is invalid', async () => { + const mockDir = createMockDirectory({ + content: { + 'path/to/template1.yaml': `invalid: definition`, + }, + }); + + await expect( + loadTemplate({ + id: 'template1', + target: mockDir.resolve('path/to/template1.yaml'), + }), + ).rejects.toThrow( + /Invalid template definition at '.*'; caused by Validation error/, + ); + }); + + it('should throw an error if target is a remote URL', async () => { + await expect( + loadTemplate({ + id: 'template1', + target: 'http://example.com', + }), + ).rejects.toThrow('Remote templates are not supported yet'); + }); + + it('should throw an error if target directory does not exist', async () => { + await expect( + loadTemplate({ + id: 'template1', + target: 'http://example.com', + }), + ).rejects.toThrow('Remote templates are not supported yet'); + }); + + it('should throw an error if template directory does not exist', async () => { + const mockDir = createMockDirectory({ + content: { + 'path/to/template1.yaml': ` + template: template1 + targetPath: plugins + `, + }, + }); + + await expect( + loadTemplate({ + id: 'template1', + target: mockDir.resolve('path/to/template1.yaml'), + }), + ).rejects.toThrow( + `Failed to load template contents from '${mockDir.resolve( + 'path/to/template1', + )}'`, + ); + }); +}); diff --git a/packages/cli/src/lib/new/preparation/loadTemplate.ts b/packages/cli/src/lib/new/preparation/loadTemplate.ts new file mode 100644 index 0000000000..bb61acdd2b --- /dev/null +++ b/packages/cli/src/lib/new/preparation/loadTemplate.ts @@ -0,0 +1,85 @@ +/* + * 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 { z } from 'zod'; +import fs from 'fs-extra'; +import { resolve as resolvePath } from 'path'; +import { dirname } from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { paths } from '../../paths'; +import { NewTemplatePointer } from '../types'; +import { NewTemplate } from '../types'; +import { ForwardedError } from '@backstage/errors'; +import { fromZodError } from 'zod-validation-error'; + +const templateDefinitionSchema = z + .object({ + description: z.string().optional(), + template: z.string(), + targetPath: z.string(), + plugin: z.boolean().optional(), + backendModulePrefix: z.boolean().optional(), + suffix: z.string().optional(), + prompts: z + .array( + z.union([ + z.string(), + z.object({ + id: z.string(), + prompt: z.string(), + validate: z.string().optional(), + default: z.union([z.string(), z.boolean(), z.number()]).optional(), + }), + ]), + ) + .optional(), + additionalActions: z.array(z.string()).optional(), + }) + .strict(); + +export async function loadTemplate({ + id, + target, +}: NewTemplatePointer): Promise { + if (target.match(/https?:\/\//)) { + throw new Error('Remote templates are not supported yet'); + } + const templateContent = await fs + .readFile(paths.resolveTargetRoot(target), 'utf-8') + .catch(error => { + throw new ForwardedError( + `Failed to load template definition from '${target}'`, + error, + ); + }); + const rawTemplate = parseYaml(templateContent); + + const parsed = templateDefinitionSchema.safeParse(rawTemplate); + if (!parsed.success) { + throw new ForwardedError( + `Invalid template definition at '${target}'`, + fromZodError(parsed.error), + ); + } + + const { template, ...templateData } = parsed.data; + + const templatePath = resolvePath(dirname(target), template); + if (!fs.existsSync(templatePath)) { + throw new Error(`Failed to load template contents from '${templatePath}'`); + } + return { id, templatePath, ...templateData }; +} diff --git a/packages/cli/src/lib/new/preparation/selectTemplateInteractively.test.ts b/packages/cli/src/lib/new/preparation/selectTemplateInteractively.test.ts new file mode 100644 index 0000000000..5346274d56 --- /dev/null +++ b/packages/cli/src/lib/new/preparation/selectTemplateInteractively.test.ts @@ -0,0 +1,85 @@ +/* + * 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 { NewConfig } from '../types'; +import inquirer from 'inquirer'; +import { withLogCollector } from '@backstage/test-utils'; +import { selectTemplateInteractively } from './selectTemplateInteractively'; + +describe('selectTemplateInteractively', () => { + const mockConfig: NewConfig = { + isUsingDefaultTemplates: false, + templatePointers: [ + { id: 'template1', target: 'path/to/template1' }, + { id: 'template2', target: 'path/to/template2' }, + ], + globals: {}, + }; + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should select a template interactively', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ id: 'template1' }); + + const result = await selectTemplateInteractively(mockConfig); + + expect(result).toEqual({ id: 'template1', target: 'path/to/template1' }); + }); + + it('should error if interactive selections is not found', async () => { + jest.spyOn(inquirer, 'prompt').mockResolvedValueOnce({ id: 'nonexistent' }); + + await expect(selectTemplateInteractively(mockConfig)).rejects.toThrow( + "Template 'nonexistent' not found", + ); + }); + + it('should use preselected template id', async () => { + const result = await selectTemplateInteractively(mockConfig, 'template2'); + + expect(result).toEqual({ id: 'template2', target: 'path/to/template2' }); + }); + + it('should throw an error if template is not found', async () => { + await expect( + selectTemplateInteractively(mockConfig, 'nonexistent'), + ).rejects.toThrow("Template 'nonexistent' not found"); + }); + + it('should rewrite plugin to frontend-plugin if default templates are used', async () => { + await expect( + selectTemplateInteractively(mockConfig, 'plugin'), + ).rejects.toThrow("Template 'plugin' not found"); + + const logs = await withLogCollector(async () => { + await expect( + selectTemplateInteractively( + { ...mockConfig, isUsingDefaultTemplates: true }, + 'plugin', + ), + ).rejects.toThrow("Template 'frontend-plugin' not found"); + }); + expect(logs).toEqual({ + log: [], + warn: [ + "DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead", + ], + error: [], + }); + }); +}); diff --git a/packages/cli/src/lib/new/preparation/selectTemplateInteractively.ts b/packages/cli/src/lib/new/preparation/selectTemplateInteractively.ts new file mode 100644 index 0000000000..83630a4ff3 --- /dev/null +++ b/packages/cli/src/lib/new/preparation/selectTemplateInteractively.ts @@ -0,0 +1,50 @@ +/* + * 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 inquirer from 'inquirer'; +import { NewConfig, NewTemplatePointer } from '../types'; + +export async function selectTemplateInteractively( + config: NewConfig, + preselectedTemplateId?: string, +): Promise { + let selectedId = preselectedTemplateId; + + if (config.isUsingDefaultTemplates && selectedId === 'plugin') { + console.warn( + `DEPRECATION WARNING: The 'plugin' template is deprecated, use 'frontend-plugin' instead`, + ); + selectedId = 'frontend-plugin'; + } + + if (!selectedId) { + const answers = await inquirer.prompt<{ id: string }>([ + { + type: 'list', + name: 'id', + message: 'What do you want to create?', + choices: config.templatePointers.map(t => t.id), + }, + ]); + selectedId = answers.id; + } + + const template = config.templatePointers.find(t => t.id === selectedId); + if (!template) { + throw new Error(`Template '${selectedId}' not found`); + } + return template; +}