From db8cb11d0fec18ebdf41bc988721b36f1c056f64 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Fri, 7 Mar 2025 23:01:28 -0600 Subject: [PATCH 1/4] factor out common parts of fetch:template and fetch:template:file actions to respective create*Handler functions Signed-off-by: Matt Benson --- .../actions/builtin/fetch/template.ts | 235 ++------------- .../builtin/fetch/templateActionHandler.ts | 272 ++++++++++++++++++ .../actions/builtin/fetch/templateFile.ts | 92 ++---- .../fetch/templateFileActionHandler.ts | 100 +++++++ 4 files changed, 412 insertions(+), 287 deletions(-) create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts 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 8dfa26057b..691583e63c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -14,10 +14,10 @@ * limitations under the License. */ -import { extname } from 'path'; -import { UrlReaderService } from '@backstage/backend-plugin-api'; -import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; -import { InputError } from '@backstage/errors'; +import { + resolveSafeChildPath, + UrlReaderService, +} from '@backstage/backend-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; import { createTemplateAction, @@ -25,13 +25,8 @@ import { TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; -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/createDefaultFilters'; import { examples } from './template.examples'; -import { convertFiltersToRecord } from '../../../../util/templating'; +import { createTemplateActionHandler } from './templateActionHandler'; /** * Downloads a skeleton, templates variables into file and directory names and content. @@ -46,17 +41,6 @@ export function createFetchTemplateAction(options: { additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; }) { - const { - reader, - integrations, - additionalTemplateFilters, - additionalTemplateGlobals, - } = options; - - const defaultTemplateFilters = convertFiltersToRecord( - createDefaultFilters({ integrations }), - ); - return createTemplateAction<{ url: string; targetPath?: string; @@ -147,201 +131,24 @@ export function createFetchTemplateAction(options: { }, }, supportsDryRun: true, - async handler(ctx) { - ctx.logger.info('Fetching template content from remote URL'); + handler: createTemplateActionHandler({ + resolveTemplate: async ctx => { + ctx.logger.info('Fetching template content from remote URL'); - const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolveSafeChildPath(workDir, 'template'); + const workDir = await ctx.createTemporaryDirectory(); + const templateDir = resolveSafeChildPath(workDir, 'template'); - const targetPath = ctx.input.targetPath ?? './'; - const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); - if (ctx.input.copyWithoutRender && ctx.input.copyWithoutTemplating) { - throw new InputError( - 'Fetch action input copyWithoutRender and copyWithoutTemplating can not be used at the same time', - ); - } + await fetchContents({ + baseUrl: ctx.templateInfo?.baseUrl, + fetchUrl: ctx.input.url, + outputPath: templateDir, + token: ctx.input.token, + ...options, + }); - let copyOnlyPatterns: string[] | undefined; - let renderFilename: boolean; - if (ctx.input.copyWithoutRender) { - ctx.logger.warn( - '[Deprecated] copyWithoutRender is deprecated Please use copyWithoutTemplating instead.', - ); - copyOnlyPatterns = ctx.input.copyWithoutRender; - renderFilename = false; - } else { - copyOnlyPatterns = ctx.input.copyWithoutTemplating; - renderFilename = true; - } - - if (copyOnlyPatterns && !Array.isArray(copyOnlyPatterns)) { - throw new InputError( - 'Fetch action input copyWithoutRender/copyWithoutTemplating must be an Array', - ); - } - - if ( - ctx.input.templateFileExtension && - (copyOnlyPatterns || ctx.input.cookiecutterCompat) - ) { - throw new InputError( - 'Fetch action input extension incompatible with copyWithoutRender/copyWithoutTemplating and cookiecutterCompat', - ); - } - - let extension: string | false = false; - if (ctx.input.templateFileExtension) { - extension = - ctx.input.templateFileExtension === true - ? '.njk' - : ctx.input.templateFileExtension; - if (!extension.startsWith('.')) { - extension = `.${extension}`; - } - } - - await fetchContents({ - reader, - integrations, - baseUrl: ctx.templateInfo?.baseUrl, - fetchUrl: ctx.input.url, - outputPath: templateDir, - token: ctx.input.token, - }); - - ctx.logger.info('Listing files and directories in template'); - const allEntriesInTemplate = await globby(`**/*`, { - cwd: templateDir, - dot: true, - onlyFiles: false, - markDirectories: true, - followSymbolicLinks: false, - }); - - const nonTemplatedEntries = new Set( - await globby(copyOnlyPatterns || [], { - cwd: templateDir, - dot: true, - onlyFiles: false, - markDirectories: true, - followSymbolicLinks: false, - }), - ); - - // Cookiecutter prefixes all parameters in templates with - // `cookiecutter.`. To replicate this, we wrap our parameters - // in an object with a `cookiecutter` property when compat - // mode is enabled. - const { cookiecutterCompat, values } = ctx.input; - const context = { - [cookiecutterCompat ? 'cookiecutter' : 'values']: values, - }; - - ctx.logger.info( - `Processing ${allEntriesInTemplate.length} template files/directories with input values`, - ctx.input.values, - ); - - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat: ctx.input.cookiecutterCompat, - templateFilters: { - ...defaultTemplateFilters, - ...(additionalTemplateFilters ?? {}), - }, - templateGlobals: additionalTemplateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); - - for (const location of allEntriesInTemplate) { - let renderContents: boolean; - - let localOutputPath = location; - if (extension) { - renderContents = extname(localOutputPath) === extension; - if (renderContents) { - localOutputPath = localOutputPath.slice(0, -extension.length); - } - // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, - // therefore the output path is always rendered. - localOutputPath = renderTemplate(localOutputPath, context); - } else { - renderContents = !nonTemplatedEntries.has(location); - // The logic here is a bit tangled because it depends on two variables. - // If renderFilename is true, which means copyWithoutTemplating is used, - // then the path is always rendered. - // If renderFilename is false, which means copyWithoutRender is used, - // then matched file/directory won't be processed, same as before. - if (renderFilename) { - localOutputPath = renderTemplate(localOutputPath, context); - } else { - localOutputPath = renderContents - ? renderTemplate(localOutputPath, context) - : localOutputPath; - } - } - - if (containsSkippedContent(localOutputPath)) { - continue; - } - - const outputPath = resolveSafeChildPath(outputDir, localOutputPath); - if (fs.existsSync(outputPath) && !ctx.input.replace) { - continue; - } - - if (!renderContents && !extension) { - ctx.logger.info( - `Copying file/directory ${location} without processing.`, - ); - } - - if (location.endsWith('/')) { - ctx.logger.info( - `Writing directory ${location} to template output path.`, - ); - await fs.ensureDir(outputPath); - } else { - const inputFilePath = resolveSafeChildPath(templateDir, location); - const stats = await fs.promises.lstat(inputFilePath); - - if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { - ctx.logger.info( - `Copying file binary or symbolic link at ${location}, to template output path.`, - ); - await fs.copy(inputFilePath, outputPath); - } else { - const statsObj = await fs.stat(inputFilePath); - ctx.logger.info( - `Writing file ${location} to template output path with mode ${statsObj.mode}.`, - ); - const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); - await fs.outputFile( - outputPath, - renderContents - ? renderTemplate(inputFileContents, context) - : inputFileContents, - { mode: statsObj.mode }, - ); - } - } - } - - ctx.logger.info(`Template result written to ${outputDir}`); - }, + return templateDir; + }, + ...options, + }), }); } - -function containsSkippedContent(localOutputPath: string): boolean { - // if the path is empty means that there is a file skipped in the root - // if the path starts with a separator it means that the root directory has been skipped - // if the path includes // means that there is a subdirectory skipped - // All paths returned are considered with / separator because of globby returning the linux separator for all os'. - return ( - localOutputPath === '' || - localOutputPath.startsWith('/') || - localOutputPath.includes('//') - ); -} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts new file mode 100644 index 0000000000..9f514899a6 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts @@ -0,0 +1,272 @@ +/* + * 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 { + isChildPath, + resolveSafeChildPath, +} from '@backstage/backend-plugin-api'; +import { InputError } from '@backstage/errors'; +import { ScmIntegrations } from '@backstage/integration'; +import { + ActionContext, + TemplateActionOptions, + TemplateFilter, + TemplateGlobal, +} from '@backstage/plugin-scaffolder-node'; +import fs from 'fs-extra'; +import globby from 'globby'; +import { isBinaryFile } from 'isbinaryfile'; +import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; +import { convertFiltersToRecord } from '../../../../util/templating'; +import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; +import { extname } from 'path'; + +type TemplateActionInput = { + targetPath?: string; + values: any; + templateFileExtension?: string | boolean; + + // Cookiecutter compat options + /** + * @deprecated This field is deprecated in favor of copyWithoutTemplating. + */ + copyWithoutRender?: string[]; + copyWithoutTemplating?: string[]; + cookiecutterCompat?: boolean; + replace?: boolean; + trimBlocks?: boolean; + lstripBlocks?: boolean; +}; + +export function createTemplateActionHandler< + I extends TemplateActionInput = TemplateActionInput, +>(options: { + resolveTemplate: (ctx: ActionContext) => Promise; + integrations: ScmIntegrations; + additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; +}): TemplateActionOptions['handler'] { + const { + resolveTemplate, + integrations, + additionalTemplateFilters, + additionalTemplateGlobals: templateGlobals, + } = options; + + const templateFilters = { + ...convertFiltersToRecord(createDefaultFilters({ integrations })), + ...additionalTemplateFilters, + }; + + return async (ctx: ActionContext) => { + const { outputDir, copyOnlyPatterns, renderFilename, extension } = + resolveTemplateActionSettings(ctx); + + const templateDir = await resolveTemplate(ctx); + + if (isChildPath(templateDir, outputDir)) { + throw new InputError('targetPath must not be within template path'); + } + + ctx.logger.info('Listing files and directories in template'); + const allEntriesInTemplate = await globby(`**/*`, { + cwd: templateDir, + dot: true, + onlyFiles: false, + markDirectories: true, + followSymbolicLinks: false, + }); + + const nonTemplatedEntries = new Set( + await globby(copyOnlyPatterns || [], { + cwd: templateDir, + dot: true, + onlyFiles: false, + markDirectories: true, + followSymbolicLinks: false, + }), + ); + + // Cookiecutter prefixes all parameters in templates with + // `cookiecutter.`. To replicate this, we wrap our parameters + // in an object with a `cookiecutter` property when compat + // mode is enabled. + const { cookiecutterCompat, values } = ctx.input; + const context = { + [cookiecutterCompat ? 'cookiecutter' : 'values']: values, + }; + + ctx.logger.info( + `Processing ${allEntriesInTemplate.length} template files/directories with input values`, + ctx.input.values, + ); + + const renderTemplate = await SecureTemplater.loadRenderer({ + cookiecutterCompat: ctx.input.cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); + + for (const location of allEntriesInTemplate) { + let renderContents: boolean; + + let localOutputPath = location; + if (extension) { + renderContents = extname(localOutputPath) === extension; + if (renderContents) { + localOutputPath = localOutputPath.slice(0, -extension.length); + } + // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, + // therefore the output path is always rendered. + localOutputPath = renderTemplate(localOutputPath, context); + } else { + renderContents = !nonTemplatedEntries.has(location); + // The logic here is a bit tangled because it depends on two variables. + // If renderFilename is true, which means copyWithoutTemplating is used, + // then the path is always rendered. + // If renderFilename is false, which means copyWithoutRender is used, + // then matched file/directory won't be processed, same as before. + if (renderFilename) { + localOutputPath = renderTemplate(localOutputPath, context); + } else { + localOutputPath = renderContents + ? renderTemplate(localOutputPath, context) + : localOutputPath; + } + } + + if (containsSkippedContent(localOutputPath)) { + continue; + } + + const outputPath = resolveSafeChildPath(outputDir, localOutputPath); + if (fs.existsSync(outputPath) && !ctx.input.replace) { + continue; + } + + if (!renderContents && !extension) { + ctx.logger.info( + `Copying file/directory ${location} without processing.`, + ); + } + + if (location.endsWith('/')) { + ctx.logger.info( + `Writing directory ${location} to template output path.`, + ); + await fs.ensureDir(outputPath); + } else { + const inputFilePath = resolveSafeChildPath(templateDir, location); + const stats = await fs.promises.lstat(inputFilePath); + + if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { + ctx.logger.info( + `Copying file binary or symbolic link at ${location}, to template output path.`, + ); + await fs.copy(inputFilePath, outputPath); + } else { + const statsObj = await fs.stat(inputFilePath); + ctx.logger.info( + `Writing file ${location} to template output path with mode ${statsObj.mode}.`, + ); + const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); + await fs.outputFile( + outputPath, + renderContents + ? renderTemplate(inputFileContents, context) + : inputFileContents, + { mode: statsObj.mode }, + ); + } + } + } + ctx.logger.info(`Template result written to ${outputDir}`); + }; +} + +function resolveTemplateActionSettings( + ctx: ActionContext, +): { + outputDir: string; + copyOnlyPatterns?: string[]; + renderFilename: boolean; + extension: string | false; +} { + const targetPath = ctx.input.targetPath ?? './'; + const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); + + if (ctx.input.copyWithoutRender && ctx.input.copyWithoutTemplating) { + throw new InputError( + 'Fetch action input copyWithoutRender and copyWithoutTemplating can not be used at the same time', + ); + } + let copyOnlyPatterns: string[] | undefined; + let renderFilename: boolean; + if (ctx.input.copyWithoutRender) { + ctx.logger.warn( + '[Deprecated] copyWithoutRender is deprecated Please use copyWithoutTemplating instead.', + ); + copyOnlyPatterns = ctx.input.copyWithoutRender; + renderFilename = false; + } else { + copyOnlyPatterns = ctx.input.copyWithoutTemplating; + renderFilename = true; + } + if (copyOnlyPatterns && !Array.isArray(copyOnlyPatterns)) { + throw new InputError( + 'Fetch action input copyWithoutRender/copyWithoutTemplating must be an Array', + ); + } + if ( + ctx.input.templateFileExtension && + (copyOnlyPatterns || ctx.input.cookiecutterCompat) + ) { + throw new InputError( + 'Fetch action input extension incompatible with copyWithoutRender/copyWithoutTemplating and cookiecutterCompat', + ); + } + let extension: string | false = false; + if (ctx.input.templateFileExtension) { + extension = + ctx.input.templateFileExtension === true + ? '.njk' + : ctx.input.templateFileExtension; + if (!extension.startsWith('.')) { + extension = `.${extension}`; + } + } + return { + outputDir, + copyOnlyPatterns, + renderFilename, + extension, + }; +} + +function containsSkippedContent(localOutputPath: string): boolean { + // if the path is empty means that there is a file skipped in the root + // if the path starts with a separator it means that the root directory has been skipped + // if the path includes // means that there is a subdirectory skipped + // All paths returned are considered with / separator because of globby returning the linux separator for all os'. + return ( + localOutputPath === '' || + localOutputPath.startsWith('/') || + localOutputPath.includes('//') + ); +} 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 2cd4ce883c..5f4dcb547d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts @@ -15,20 +15,16 @@ */ import { UrlReaderService } from '@backstage/backend-plugin-api'; -import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; -import { examples } from './templateFile.examples'; import { createTemplateAction, fetchFile, TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; -import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; -import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; import path from 'path'; -import fs from 'fs-extra'; -import { convertFiltersToRecord } from '../../../../util/templating'; +import { examples } from './templateFile.examples'; +import { createTemplateFileActionHandler } from './templateFileActionHandler'; /** * Downloads a single file and templates variables into file. @@ -42,17 +38,6 @@ export function createFetchTemplateFileAction(options: { additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; }) { - const { - reader, - integrations, - additionalTemplateFilters, - additionalTemplateGlobals, - } = options; - - const defaultTemplateFilters = convertFiltersToRecord( - createDefaultFilters({ integrations }), - ); - return createTemplateAction<{ url: string; targetPath: string; @@ -110,63 +95,24 @@ export function createFetchTemplateFileAction(options: { }, }, supportsDryRun: true, - async handler(ctx) { - ctx.logger.info('Fetching template file content from remote URL'); + handler: createTemplateFileActionHandler({ + resolveTemplateFile: async ctx => { + ctx.logger.info('Fetching template file content from remote URL'); - const workDir = await ctx.createTemporaryDirectory(); - // Write to a tmp file, render the template, then copy to workspace. - const tmpFilePath = path.join(workDir, 'tmp'); + const workDir = await ctx.createTemporaryDirectory(); + // Write to a tmp file, render the template, then copy to workspace. + const tmpFilePath = path.join(workDir, 'tmp'); - const outputPath = resolveSafeChildPath( - ctx.workspacePath, - ctx.input.targetPath, - ); - - if (fs.existsSync(outputPath) && !ctx.input.replace) { - ctx.logger.info( - `File ${ctx.input.targetPath} already exists in workspace, not replacing.`, - ); - return; - } - - await fetchFile({ - reader, - integrations, - baseUrl: ctx.templateInfo?.baseUrl, - fetchUrl: ctx.input.url, - outputPath: tmpFilePath, - token: ctx.input.token, - }); - - const { cookiecutterCompat, values } = ctx.input; - const context = { - [cookiecutterCompat ? 'cookiecutter' : 'values']: values, - }; - - ctx.logger.info( - `Processing template file with input values`, - ctx.input.values, - ); - - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat, - templateFilters: { - ...defaultTemplateFilters, - ...additionalTemplateFilters, - }, - templateGlobals: additionalTemplateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); - - const contents = await fs.readFile(tmpFilePath, 'utf-8'); - const result = renderTemplate(contents, context); - await fs.ensureDir(path.dirname(outputPath)); - await fs.outputFile(outputPath, result); - - ctx.logger.info(`Template file has been written to ${outputPath}`); - }, + await fetchFile({ + baseUrl: ctx.templateInfo?.baseUrl, + fetchUrl: ctx.input.url, + outputPath: tmpFilePath, + token: ctx.input.token, + ...options, + }); + return tmpFilePath; + }, + ...options, + }), }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts new file mode 100644 index 0000000000..dce9b896dd --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts @@ -0,0 +1,100 @@ +/* + * 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 { ScmIntegrations } from '@backstage/integration'; +import { + ActionContext, + TemplateActionOptions, + TemplateFilter, + TemplateGlobal, +} from '@backstage/plugin-scaffolder-node'; +import fs from 'fs-extra'; +import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; +import { convertFiltersToRecord } from '../../../../util/templating'; +import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; +import path from 'path'; +import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; + +export type TemplateFileActionInput = { + targetPath: string; + values: any; + cookiecutterCompat?: boolean; + replace?: boolean; + trimBlocks?: boolean; + lstripBlocks?: boolean; +}; + +export function createTemplateFileActionHandler< + I extends TemplateFileActionInput = TemplateFileActionInput, +>(options: { + resolveTemplateFile: (ctx: ActionContext) => Promise; + integrations: ScmIntegrations; + additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; +}): TemplateActionOptions['handler'] { + const { + resolveTemplateFile, + integrations, + additionalTemplateFilters, + additionalTemplateGlobals: templateGlobals, + } = options; + + const templateFilters = { + ...convertFiltersToRecord(createDefaultFilters({ integrations })), + ...additionalTemplateFilters, + }; + + return async (ctx: ActionContext) => { + const outputPath = resolveSafeChildPath( + ctx.workspacePath, + ctx.input.targetPath, + ); + + if (fs.existsSync(outputPath) && !ctx.input.replace) { + ctx.logger.info( + `File ${ctx.input.targetPath} already exists in workspace, not replacing.`, + ); + return; + } + const filePath = await resolveTemplateFile(ctx); + + const { cookiecutterCompat, values } = ctx.input; + const context = { + [cookiecutterCompat ? 'cookiecutter' : 'values']: values, + }; + + ctx.logger.info( + `Processing template file with input values`, + ctx.input.values, + ); + + const renderTemplate = await SecureTemplater.loadRenderer({ + cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); + + const contents = await fs.readFile(filePath, 'utf-8'); + const result = renderTemplate(contents, context); + await fs.ensureDir(path.dirname(outputPath)); + await fs.outputFile(outputPath, result); + + ctx.logger.info(`Template file has been written to ${outputPath}`); + }; +} From 587cb050a5c46b6b1c2f829405e8ac24edeeee99 Mon Sep 17 00:00:00 2001 From: Matt Benson Date: Sat, 8 Mar 2025 00:21:49 -0600 Subject: [PATCH 2/4] Added workspace:template and workspace:template:file actions to complement respective fetch:\* actions Signed-off-by: Matt Benson --- .changeset/heavy-onions-swim.md | 5 + .../builtin/fetch/templateActionHandler.ts | 2 +- .../builtin/fetch/workspaceTemplate.test.ts | 647 ++++++++++++++++++ .../builtin/fetch/workspaceTemplate.ts | 102 +++ .../fetch/workspaceTemplateFile.test.ts | 206 ++++++ .../builtin/fetch/workspaceTemplateFile.ts | 77 +++ 6 files changed, 1038 insertions(+), 1 deletion(-) create mode 100644 .changeset/heavy-onions-swim.md create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts diff --git a/.changeset/heavy-onions-swim.md b/.changeset/heavy-onions-swim.md new file mode 100644 index 0000000000..137a2379f7 --- /dev/null +++ b/.changeset/heavy-onions-swim.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Added workspace:template and workspace:template:file actions to complement respective fetch:\* actions diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts index 9f514899a6..9cfd85b348 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts @@ -33,7 +33,7 @@ import { convertFiltersToRecord } from '../../../../util/templating'; import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; import { extname } from 'path'; -type TemplateActionInput = { +export type TemplateActionInput = { targetPath?: string; values: any; templateFileExtension?: string | boolean; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.test.ts new file mode 100644 index 0000000000..92b17aa2c9 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.test.ts @@ -0,0 +1,647 @@ +/* + * 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 { resolvePackagePath } from '@backstage/backend-plugin-api'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { + ActionContext, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import fs from 'fs-extra'; +import { join as joinPath, sep as pathSep } from 'path'; +import { createWorkspaceTemplateAction } from './workspaceTemplate'; +import { TemplateActionInput } from './templateActionHandler'; + +type WorkspaceTemplateInput = TemplateActionInput & { + sourcePath: string; +}; + +const aBinaryFile = fs.readFileSync( + resolvePackagePath( + '@backstage/plugin-scaffolder-backend', + 'fixtures/test-nested-template/public/react-logo192.png', + ), +); + +describe('workspace:template', () => { + let action: TemplateAction; + + const mockDir = createMockDirectory(); + const workspacePath = mockDir.resolve('workspace'); + + const mockContext = (inputPatch: Partial = {}) => + createMockActionContext({ + templateInfo: { + baseUrl: 'base-url', + entityRef: 'template:default/test-template', + }, + input: { + sourcePath: './skeleton', + targetPath: './target', + values: { + test: 'value', + }, + ...inputPatch, + }, + workspacePath, + }); + + beforeEach(() => { + mockDir.setContent({ + workspace: {}, + }); + action = createWorkspaceTemplateAction({ + integrations: Symbol('Integrations') as unknown as ScmIntegrations, + }); + }); + + it(`returns a TemplateAction with the id 'workspace:template'`, () => { + expect(action.id).toEqual('workspace:template'); + }); + + describe('handler', () => { + it('throws if output directory is outside the workspace', async () => { + await expect(() => + action.handler(mockContext({ targetPath: '../' })), + ).rejects.toThrow( + /relative path is not allowed to refer to a directory outside its parent/i, + ); + }); + + it('throws if cookiecutterCompat is used with extension', async () => { + await expect(() => + action.handler( + mockContext({ + cookiecutterCompat: true, + templateFileExtension: true, + }), + ), + ).rejects.toThrow( + /input extension incompatible with copyWithoutRender\/copyWithoutTemplating and cookiecutterCompat/, + ); + }); + + it('throws if targetPath overlaps sourcePath', async () => { + await expect(() => + action.handler( + mockContext({ + sourcePath: '.', + }), + ), + ).rejects.toThrow('targetPath must not be within template path'); + }); + + describe('with optional directories / files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + showDummyFile: false, + skipRootDirectory: true, + skipSubdirectory: true, + skipMultiplesDirectories: true, + skipFileInsideDirectory: true, + }, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + '{% if values.showDummyFile %}dummy-file.txt{% else %}{% endif %}': + 'dummy file', + '${{ "dummy-file2.txt" if values.showDummyFile else "" }}': + 'some dummy file', + '${{ "dummy-dir" if not values.skipRootDirectory else "" }}': { + 'file.txt': 'file inside optional directory', + subdir: { + '${{ "dummy-subdir" if not values.skipSubdirectory else "" }}': + 'file inside optional subdirectory', + }, + }, + subdir2: { + '${{ "dummy-subdir" if not values.skipMultiplesDirectories else "" }}': + { + '${{ "dummy-subdir" if not values.skipMultiplesDirectories else "" }}': + { + 'multipleDirectorySkippedFile.txt': + 'file inside multiple optional subdirectories', + }, + }, + }, + subdir3: { + '${{ "fileSkippedInsideDirectory.txt" if not values.skipFileInsideDirectory else "" }}': + 'skipped file inside directory', + }, + }, + }, + }); + + await action.handler(context); + }); + + it('skips empty filename', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty filename syntax #2', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-file2.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty directory', async () => { + await expect( + fs.pathExists(`${workspacePath}/target/dummy-dir/dummy-file3.txt`), + ).resolves.toEqual(false); + }); + + it('skips empty filename inside directory', async () => { + await expect( + fs.pathExists( + `${workspacePath}/target/subdir3/fileSkippedInsideDirectory.txt`, + ), + ).resolves.toEqual(false); + }); + + it('skips content of empty subdirectory', async () => { + await expect( + fs.pathExists( + `${workspacePath}/target/subdir2/multipleDirectorySkippedFile.txt`, + ), + ).resolves.toEqual(false); + + await expect( + fs.pathExists( + `${workspacePath}/target/subdir2/dummy-subdir/dummy-subdir/multipleDirectorySkippedFile.txt`, + ), + ).resolves.toEqual(false); + }); + }); + + describe('with valid input', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + showDummyFile: false, + }, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + 'empty-dir-${{ values.count }}': {}, + 'static.txt': 'static content', + '${{ values.name }}.txt': 'static content', + subdir: { + 'templated-content.txt': + '${{ values.name }}: ${{ values.count }}', + }, + '.${{ values.name }}': '${{ values.itemList | dump }}', + 'a-binary-file.png': aBinaryFile, + 'an-executable.sh': ctx => + fs.writeFileSync(ctx.path, '#!/usr/bin/env bash', { + encoding: 'utf-8', + mode: parseInt('100755', 8), + }), + symlink: ctx => ctx.symlink('a-binary-file.png'), + brokenSymlink: ctx => ctx.symlink('./not-a-real-file.txt'), + }, + }, + }); + + await action.handler(context); + }); + + it('copies files with no templating in names or content successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies files with templated names successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies files with templated content successfully', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/subdir/templated-content.txt`, + 'utf-8', + ), + ).resolves.toEqual('test-project: 1234'); + }); + + it('processes dotfiles', async () => { + await expect( + fs.readFile(`${workspacePath}/target/.test-project`, 'utf-8'), + ).resolves.toEqual('["first","second","third"]'); + }); + + it('copies empty directories', async () => { + await expect( + fs.readdir(`${workspacePath}/target/empty-dir-1234`, 'utf-8'), + ).resolves.toEqual([]); + }); + + it('copies binary files as-is without processing them', async () => { + await expect( + fs.readFile(`${workspacePath}/target/a-binary-file.png`), + ).resolves.toEqual(aBinaryFile); + }); + + it('copies files and maintains the original file permissions', async () => { + await expect( + fs + .stat(`${workspacePath}/target/an-executable.sh`) + .then(fObj => fObj.mode), + ).resolves.toEqual(parseInt('100755', 8)); + }); + + it('copies file symlinks as-is without processing them', async () => { + await expect( + fs + .lstat(`${workspacePath}/target/symlink`) + .then(i => i.isSymbolicLink()), + ).resolves.toBe(true); + + await expect( + fs.realpath(`${workspacePath}/target/symlink`), + ).resolves.toBe( + fs.realpathSync( + joinPath(workspacePath, 'target', 'a-binary-file.png'), + ), + ); + }); + + it('copies broken symlinks as-is without processing them', async () => { + await expect( + fs + .lstat(`${workspacePath}/target/brokenSymlink`) + .then(i => i.isSymbolicLink()), + ).resolves.toBe(true); + + await expect( + fs.readlink(`${workspacePath}/target/brokenSymlink`), + ).resolves.toEqual(`.${pathSep}not-a-real-file.txt`); + }); + }); + }); + + describe('copyWithoutTemplating', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + copyWithoutTemplating: ['.unprocessed'], + }); + + mockDir.setContent({ + workspace: { + skeleton: { + processed: { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', + }, + '.unprocessed': { + 'templated-content-${{ values.name }}.txt': '${{ values.count }}', + }, + }, + }, + }); + + await action.handler(context); + }); + + it('renders path template and ignores content template in files matched in copyWithoutTemplating', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/.unprocessed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('${{ values.count }}'); + }); + + it('processes files not matched in copyWithoutTemplating', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/processed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('1234'); + }); + + describe('with exclusion filter', () => { + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + copyWithoutTemplating: [ + '.unprocessed', + '!*/templated-process-content-${{ values.name }}.txt', + ], + }); + + mockDir.setContent({ + workspace: { + skeleton: { + processed: { + 'templated-content-${{ values.name }}.txt': + '${{ values.count }}', + }, + '.unprocessed': { + 'templated-content-${{ values.name }}.txt': + '${{ values.count }}', + 'templated-process-content-${{ values.name }}.txt': + '${{ values.count }}', + }, + }, + }, + }); + + await action.handler(context); + }); + + it('renders path template including excluded matches in copyWithoutTemplating', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/.unprocessed/templated-process-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('1234'); + await expect( + fs.readFile( + `${workspacePath}/target/.unprocessed/templated-content-test-project.txt`, + 'utf-8', + ), + ).resolves.toEqual('${{ values.count }}'); + }); + }); + }); + + describe('cookiecutter compatibility mode', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + cookiecutterCompat: true, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + '{{ cookiecutter.name }}.txt': 'static content', + subdir: { + 'templated-content.txt': + '{{ cookiecutter.name }}: {{ cookiecutter.count }}', + }, + '{{ cookiecutter.name }}.json': + '{{ cookiecutter.itemList | jsonify }}', + }, + }, + }); + + await action.handler(context); + }); + + it('copies files with cookiecutter-style templated names successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies files with cookiecutter-style templated content successfully', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/subdir/templated-content.txt`, + 'utf-8', + ), + ).resolves.toEqual('test-project: 1234'); + }); + + it('includes the jsonify filter', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.json`, 'utf-8'), + ).resolves.toEqual('["first","second","third"]'); + }); + }); + + describe('with extension=true', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + templateFileExtension: true, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + 'empty-dir-${{ values.count }}': {}, + 'static.txt': 'static content', + '${{ values.name }}.txt': 'static content', + '${{ values.name }}.txt.jinja2': + '${{ values.name }}: ${{ values.count }}', + subdir: { + 'templated-content.txt.njk': + '${{ values.name }}: ${{ values.count }}', + }, + '.${{ values.name }}.njk': '${{ values.itemList | dump }}', + 'a-binary-file.png': aBinaryFile, + }, + }, + }); + + await action.handler(context); + }); + + it('copies files with no templating in names or content successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies files with templated names successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual('static content'); + }); + + it('copies jinja2 files with templated names successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt.jinja2`, 'utf-8'), + ).resolves.toEqual('${{ values.name }}: ${{ values.count }}'); + }); + + it('copies files with templated content successfully', async () => { + await expect( + fs.readFile( + `${workspacePath}/target/subdir/templated-content.txt`, + 'utf-8', + ), + ).resolves.toEqual('test-project: 1234'); + }); + + it('processes dotfiles', async () => { + await expect( + fs.readFile(`${workspacePath}/target/.test-project`, 'utf-8'), + ).resolves.toEqual('["first","second","third"]'); + }); + + it('copies empty directories', async () => { + await expect( + fs.readdir(`${workspacePath}/target/empty-dir-1234`, 'utf-8'), + ).resolves.toEqual([]); + }); + + it('copies binary files as-is without processing them', async () => { + await expect( + fs.readFile(`${workspacePath}/target/a-binary-file.png`), + ).resolves.toEqual(aBinaryFile); + }); + }); + + describe('with specified .jinja2 extension', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + templateFileExtension: '.jinja2', + values: { + name: 'test-project', + count: 1234, + }, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + '${{ values.name }}.njk': '${{ values.name }}: ${{ values.count }}', + '${{ values.name }}.txt.jinja2': + '${{ values.name }}: ${{ values.count }}', + }, + }, + }); + + await action.handler(context); + }); + + it('does not process .njk files', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.njk`, 'utf-8'), + ).resolves.toEqual('${{ values.name }}: ${{ values.count }}'); + }); + + it('does process .jinja2 files', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual('test-project: 1234'); + }); + }); + + describe('with replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + replace: true, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + }, + target: { + 'static-content.txt': 'static-content', + }, + }, + }); + + await action.handler(context); + }); + + it('overwrites existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('test-project: 1234'); + }); + }); + + describe('without replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + }, + targetPath: './target', + replace: false, + }); + + mockDir.setContent({ + workspace: { + skeleton: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + }, + target: { + 'static-content.txt': 'static-content', + }, + }, + }); + + await action.handler(context); + }); + + it('keeps existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('static-content'); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts new file mode 100644 index 0000000000..6e2be87757 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts @@ -0,0 +1,102 @@ +/* + * Copyright 2021 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 { resolveSafeChildPath } from '@backstage/backend-plugin-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { + createTemplateAction, + TemplateFilter, + TemplateGlobal, +} from '@backstage/plugin-scaffolder-node'; +import { examples } from './template.examples'; +import { createTemplateActionHandler } from './templateActionHandler'; + +/** + * Templates variables into file and directory names and content of 'sourcePath' in the action context workspace. + * Then places the result into a subdirectory of the workspace specified by the 'targetPath' input option. + * + * @public + */ +export function createWorkspaceTemplateAction(options: { + integrations: ScmIntegrations; + additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; +}) { + return createTemplateAction({ + id: 'workspace:template', + description: + 'Templates variables into file and directory names and content of `sourcePath` in the action context workspace. Then places the result into a subdirectory of the workspace specified by the `targetPath` input option.', + examples, + schema: { + input: { + sourcePath: z => + z + .string() + .describe( + 'Path within the working directory denoting source template.', + ), + targetPath: z => + z + .string() + .describe( + 'Target path within the working directory to download the contents to; must not overlap `sourcePath`.', + ), + values: z => + z + .record(z.any()) + .optional() + .describe('Values to pass to the templating engine.'), + copyWithoutTemplating: z => + z + .array(z.string()) + .describe( + 'An array of glob patterns. Contents of matched files or directories are copied without being processed, but paths are subject to rendering.', + ), + cookiecutterCompat: z => + z + .boolean() + .optional() + .describe( + 'Enable features to maximise compatibility with templates built for fetch:cookiecutter', + ), + templateFileExtension: z => + z + .string() + .or(z.boolean()) + .optional() + .describe( + 'If set, only files with the given extension will be templated. If set to `true`, the default extension `.njk` is used.', + ), + replace: z => + z + .boolean() + .optional() + .describe( + 'If set, replace files in targetPath instead of skipping existing ones.', + ), + }, + }, + supportsDryRun: true, + handler: createTemplateActionHandler({ + resolveTemplate: async ctx => + resolveSafeChildPath( + ctx.workspacePath, + (ctx.input as any as { sourcePath: string }).sourcePath, + ), + ...options, + }), + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.test.ts new file mode 100644 index 0000000000..9436b160c5 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.test.ts @@ -0,0 +1,206 @@ +/* + * Copyright 2024 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 { ScmIntegrations } from '@backstage/integration'; +import { + ActionContext, + TemplateAction, +} from '@backstage/plugin-scaffolder-node'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import fs from 'fs-extra'; +import { join as joinPath } from 'path'; +import { createWorkspaceTemplateFileAction } from './workspaceTemplateFile'; +import { TemplateFileActionInput } from './templateFileActionHandler'; + +type WorkspaceTemplateInput = TemplateFileActionInput & { sourcePath: string }; + +describe('fetch:template:file', () => { + let action: TemplateAction; + + const mockDir = createMockDirectory(); + const workspacePath = mockDir.resolve('workspace'); + const mockContext = (inputPatch: Partial = {}) => + createMockActionContext({ + templateInfo: { + baseUrl: 'base-url', + entityRef: 'template:default/test-template', + }, + input: { + sourcePath: './skeleton.txt', + targetPath: './target/skeleton.txt', + values: { + test: 'value', + }, + ...inputPatch, + }, + workspacePath, + }); + + beforeEach(() => { + mockDir.setContent({ + workspace: {}, + }); + action = createWorkspaceTemplateFileAction({ + integrations: Symbol('Integrations') as unknown as ScmIntegrations, + }); + }); + + it(`returns a TemplateAction with the id 'workspace:template:file'`, () => { + expect(action.id).toEqual('workspace:template:file'); + }); + + describe('handler', () => { + it('should disallow a target path outside working directory', async () => { + await expect( + action.handler(mockContext({ targetPath: '../' })), + ).rejects.toThrow( + /Relative path is not allowed to refer to a directory outside its parent/, + ); + }); + + describe('valid input', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + }); + + mockDir.setContent({ + workspace: { + 'skeleton.txt': + '${{ values.name }}: ${{ values.count }} ${{ values.itemList | dump }}', + }, + }); + + await action.handler(context); + }); + + it('templates content successfully', async () => { + await expect( + fs.readFile( + joinPath(workspacePath, context.input.targetPath), + 'utf-8', + ), + ).resolves.toEqual('test-project: 1234 ["first","second","third"]'); + }); + }); + + describe('with replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + sourcePath: './static-content.txt', + targetPath: './target/static-content.txt', + values: { + name: 'test-project', + count: 1234, + }, + replace: true, + }); + + mockDir.setContent({ + workspace: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + target: { + 'static-content.txt': 'static-content', + }, + }, + }); + + await action.handler(context); + }); + + it('overwrites existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('test-project: 1234'); + }); + }); + + describe('without replacement of existing files', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + sourcePath: './static-content.txt', + targetPath: './target/static-content.txt', + values: { + name: 'test-project', + count: 1234, + }, + replace: false, + }); + + mockDir.setContent({ + workspace: { + 'static-content.txt': '${{ values.name }}: ${{ values.count }}', + target: { + 'static-content.txt': 'static-content', + }, + }, + }); + + await action.handler(context); + }); + + it('keeps existing file', async () => { + await expect( + fs.readFile(`${workspacePath}/target/static-content.txt`, 'utf-8'), + ).resolves.toEqual('static-content'); + }); + }); + + describe('cookiecutter compatibility mode', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + targetPath: './target/test-project.txt', + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + cookiecutterCompat: true, + }); + + mockDir.setContent({ + workspace: { + 'skeleton.txt': + 'static:{{ cookiecutter.name }}:{{ cookiecutter.count }}:{{ cookiecutter.itemList | jsonify }}', + }, + }); + + await action.handler(context); + }); + + it('copies files with cookiecutter-style templated variables successfully', async () => { + await expect( + fs.readFile(`${workspacePath}/target/test-project.txt`, 'utf-8'), + ).resolves.toEqual( + 'static:test-project:1234:["first","second","third"]', + ); + }); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts new file mode 100644 index 0000000000..57a6ad881c --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts @@ -0,0 +1,77 @@ +/* + * Copyright 2024 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 { resolveSafeChildPath } from '@backstage/backend-plugin-api'; +import { ScmIntegrations } from '@backstage/integration'; +import { + createTemplateAction, + TemplateFilter, + TemplateGlobal, +} from '@backstage/plugin-scaffolder-node'; +import { examples } from './templateFile.examples'; +import { createTemplateFileActionHandler } from './templateFileActionHandler'; + +/** + * Templates variables into a single workspace file, placing the result into another location in the workspace. + * @public + */ +export function createWorkspaceTemplateFileAction(options: { + integrations: ScmIntegrations; + additionalTemplateFilters?: Record; + additionalTemplateGlobals?: Record; +}) { + return createTemplateAction({ + id: 'workspace:template:file', + description: + 'Templates variables into a single workspace file, placing the result into another location in the workspace.', + examples, + schema: { + input: { + sourcePath: z => + z.string().describe('Path in workspace to source file.'), + targetPath: z => z.string().describe('Target path in workspace.'), + values: z => + z + .record(z.any()) + .optional() + .describe('Values to pass to the templating engine.'), + cookiecutterCompat: z => + z + .boolean() + .optional() + .describe( + 'Enable features to maximise compatibility with templates built for fetch:cookiecutter', + ), + replace: z => + z + .boolean() + .optional() + .describe( + 'If set, replace files in targetPath instead of skipping existing ones.', + ), + }, + }, + supportsDryRun: true, + handler: createTemplateFileActionHandler({ + resolveTemplateFile: async ctx => + resolveSafeChildPath( + ctx.workspacePath, + (ctx.input as any as { sourcePath: string }).sourcePath, + ), + ...options, + }), + }); +} From 8c9851f13f64b109497a35b2d549a381f1c57ba9 Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 29 Apr 2025 14:56:44 +0200 Subject: [PATCH 3/4] chore: refactor a little bit to keep the typings from the original context Signed-off-by: benjdlambert --- .../actions/builtin/fetch/template.ts | 34 +-- .../builtin/fetch/templateActionHandler.ts | 227 +++++++++--------- .../actions/builtin/fetch/templateFile.ts | 36 +-- .../fetch/templateFileActionHandler.ts | 79 +++--- .../builtin/fetch/workspaceTemplate.ts | 15 +- .../builtin/fetch/workspaceTemplateFile.ts | 15 +- 6 files changed, 201 insertions(+), 205 deletions(-) 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 691583e63c..4a80cd208d 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -131,24 +131,26 @@ export function createFetchTemplateAction(options: { }, }, supportsDryRun: true, - handler: createTemplateActionHandler({ - resolveTemplate: async ctx => { - ctx.logger.info('Fetching template content from remote URL'); + handler: ctx => + createTemplateActionHandler({ + ctx, + resolveTemplate: async () => { + ctx.logger.info('Fetching template content from remote URL'); - const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolveSafeChildPath(workDir, 'template'); + const workDir = await ctx.createTemporaryDirectory(); + const templateDir = resolveSafeChildPath(workDir, 'template'); - await fetchContents({ - baseUrl: ctx.templateInfo?.baseUrl, - fetchUrl: ctx.input.url, - outputPath: templateDir, - token: ctx.input.token, - ...options, - }); + await fetchContents({ + baseUrl: ctx.templateInfo?.baseUrl, + fetchUrl: ctx.input.url, + outputPath: templateDir, + token: ctx.input.token, + ...options, + }); - return templateDir; - }, - ...options, - }), + return templateDir; + }, + ...options, + }), }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts index 9cfd85b348..f280841bfa 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts @@ -21,7 +21,6 @@ import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; import { ActionContext, - TemplateActionOptions, TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; @@ -50,19 +49,21 @@ export type TemplateActionInput = { lstripBlocks?: boolean; }; -export function createTemplateActionHandler< - I extends TemplateActionInput = TemplateActionInput, +export async function createTemplateActionHandler< + I extends TemplateActionInput, >(options: { - resolveTemplate: (ctx: ActionContext) => Promise; + ctx: ActionContext; + resolveTemplate: () => Promise; integrations: ScmIntegrations; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; -}): TemplateActionOptions['handler'] { +}) { const { resolveTemplate, integrations, additionalTemplateFilters, additionalTemplateGlobals: templateGlobals, + ctx, } = options; const templateFilters = { @@ -70,134 +71,128 @@ export function createTemplateActionHandler< ...additionalTemplateFilters, }; - return async (ctx: ActionContext) => { - const { outputDir, copyOnlyPatterns, renderFilename, extension } = - resolveTemplateActionSettings(ctx); + const { outputDir, copyOnlyPatterns, renderFilename, extension } = + resolveTemplateActionSettings(ctx); - const templateDir = await resolveTemplate(ctx); + const templateDir = await resolveTemplate(); - if (isChildPath(templateDir, outputDir)) { - throw new InputError('targetPath must not be within template path'); - } + if (isChildPath(templateDir, outputDir)) { + throw new InputError('targetPath must not be within template path'); + } - ctx.logger.info('Listing files and directories in template'); - const allEntriesInTemplate = await globby(`**/*`, { + ctx.logger.info('Listing files and directories in template'); + const allEntriesInTemplate = await globby(`**/*`, { + cwd: templateDir, + dot: true, + onlyFiles: false, + markDirectories: true, + followSymbolicLinks: false, + }); + + const nonTemplatedEntries = new Set( + await globby(copyOnlyPatterns || [], { cwd: templateDir, dot: true, onlyFiles: false, markDirectories: true, followSymbolicLinks: false, - }); + }), + ); - const nonTemplatedEntries = new Set( - await globby(copyOnlyPatterns || [], { - cwd: templateDir, - dot: true, - onlyFiles: false, - markDirectories: true, - followSymbolicLinks: false, - }), - ); + // Cookiecutter prefixes all parameters in templates with + // `cookiecutter.`. To replicate this, we wrap our parameters + // in an object with a `cookiecutter` property when compat + // mode is enabled. + const { cookiecutterCompat, values } = ctx.input; + const context = { + [cookiecutterCompat ? 'cookiecutter' : 'values']: values, + }; - // Cookiecutter prefixes all parameters in templates with - // `cookiecutter.`. To replicate this, we wrap our parameters - // in an object with a `cookiecutter` property when compat - // mode is enabled. - const { cookiecutterCompat, values } = ctx.input; - const context = { - [cookiecutterCompat ? 'cookiecutter' : 'values']: values, - }; + ctx.logger.info( + `Processing ${allEntriesInTemplate.length} template files/directories with input values`, + ctx.input.values, + ); - ctx.logger.info( - `Processing ${allEntriesInTemplate.length} template files/directories with input values`, - ctx.input.values, - ); + const renderTemplate = await SecureTemplater.loadRenderer({ + cookiecutterCompat: ctx.input.cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat: ctx.input.cookiecutterCompat, - templateFilters, - templateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); + for (const location of allEntriesInTemplate) { + let renderContents: boolean; - for (const location of allEntriesInTemplate) { - let renderContents: boolean; - - let localOutputPath = location; - if (extension) { - renderContents = extname(localOutputPath) === extension; - if (renderContents) { - localOutputPath = localOutputPath.slice(0, -extension.length); - } - // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, - // therefore the output path is always rendered. + let localOutputPath = location; + if (extension) { + renderContents = extname(localOutputPath) === extension; + if (renderContents) { + localOutputPath = localOutputPath.slice(0, -extension.length); + } + // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, + // therefore the output path is always rendered. + localOutputPath = renderTemplate(localOutputPath, context); + } else { + renderContents = !nonTemplatedEntries.has(location); + // The logic here is a bit tangled because it depends on two variables. + // If renderFilename is true, which means copyWithoutTemplating is used, + // then the path is always rendered. + // If renderFilename is false, which means copyWithoutRender is used, + // then matched file/directory won't be processed, same as before. + if (renderFilename) { localOutputPath = renderTemplate(localOutputPath, context); } else { - renderContents = !nonTemplatedEntries.has(location); - // The logic here is a bit tangled because it depends on two variables. - // If renderFilename is true, which means copyWithoutTemplating is used, - // then the path is always rendered. - // If renderFilename is false, which means copyWithoutRender is used, - // then matched file/directory won't be processed, same as before. - if (renderFilename) { - localOutputPath = renderTemplate(localOutputPath, context); - } else { - localOutputPath = renderContents - ? renderTemplate(localOutputPath, context) - : localOutputPath; - } - } - - if (containsSkippedContent(localOutputPath)) { - continue; - } - - const outputPath = resolveSafeChildPath(outputDir, localOutputPath); - if (fs.existsSync(outputPath) && !ctx.input.replace) { - continue; - } - - if (!renderContents && !extension) { - ctx.logger.info( - `Copying file/directory ${location} without processing.`, - ); - } - - if (location.endsWith('/')) { - ctx.logger.info( - `Writing directory ${location} to template output path.`, - ); - await fs.ensureDir(outputPath); - } else { - const inputFilePath = resolveSafeChildPath(templateDir, location); - const stats = await fs.promises.lstat(inputFilePath); - - if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { - ctx.logger.info( - `Copying file binary or symbolic link at ${location}, to template output path.`, - ); - await fs.copy(inputFilePath, outputPath); - } else { - const statsObj = await fs.stat(inputFilePath); - ctx.logger.info( - `Writing file ${location} to template output path with mode ${statsObj.mode}.`, - ); - const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); - await fs.outputFile( - outputPath, - renderContents - ? renderTemplate(inputFileContents, context) - : inputFileContents, - { mode: statsObj.mode }, - ); - } + localOutputPath = renderContents + ? renderTemplate(localOutputPath, context) + : localOutputPath; } } - ctx.logger.info(`Template result written to ${outputDir}`); - }; + + if (containsSkippedContent(localOutputPath)) { + continue; + } + + const outputPath = resolveSafeChildPath(outputDir, localOutputPath); + if (fs.existsSync(outputPath) && !ctx.input.replace) { + continue; + } + + if (!renderContents && !extension) { + ctx.logger.info(`Copying file/directory ${location} without processing.`); + } + + if (location.endsWith('/')) { + ctx.logger.info(`Writing directory ${location} to template output path.`); + await fs.ensureDir(outputPath); + } else { + const inputFilePath = resolveSafeChildPath(templateDir, location); + const stats = await fs.promises.lstat(inputFilePath); + + if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { + ctx.logger.info( + `Copying file binary or symbolic link at ${location}, to template output path.`, + ); + await fs.copy(inputFilePath, outputPath); + } else { + const statsObj = await fs.stat(inputFilePath); + ctx.logger.info( + `Writing file ${location} to template output path with mode ${statsObj.mode}.`, + ); + const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); + await fs.outputFile( + outputPath, + renderContents + ? renderTemplate(inputFileContents, context) + : inputFileContents, + { mode: statsObj.mode }, + ); + } + } + } + ctx.logger.info(`Template result written to ${outputDir}`); } function resolveTemplateActionSettings( 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 5f4dcb547d..e70e9435c5 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFile.ts @@ -95,24 +95,26 @@ export function createFetchTemplateFileAction(options: { }, }, supportsDryRun: true, - handler: createTemplateFileActionHandler({ - resolveTemplateFile: async ctx => { - ctx.logger.info('Fetching template file content from remote URL'); + handler: ctx => + createTemplateFileActionHandler({ + ctx, + resolveTemplateFile: async () => { + ctx.logger.info('Fetching template file content from remote URL'); - const workDir = await ctx.createTemporaryDirectory(); - // Write to a tmp file, render the template, then copy to workspace. - const tmpFilePath = path.join(workDir, 'tmp'); + const workDir = await ctx.createTemporaryDirectory(); + // Write to a tmp file, render the template, then copy to workspace. + const tmpFilePath = path.join(workDir, 'tmp'); - await fetchFile({ - baseUrl: ctx.templateInfo?.baseUrl, - fetchUrl: ctx.input.url, - outputPath: tmpFilePath, - token: ctx.input.token, - ...options, - }); - return tmpFilePath; - }, - ...options, - }), + await fetchFile({ + baseUrl: ctx.templateInfo?.baseUrl, + fetchUrl: ctx.input.url, + outputPath: tmpFilePath, + token: ctx.input.token, + ...options, + }); + return tmpFilePath; + }, + ...options, + }), }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts index dce9b896dd..d82a1dd5a2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts @@ -16,7 +16,6 @@ import { ScmIntegrations } from '@backstage/integration'; import { ActionContext, - TemplateActionOptions, TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; @@ -36,19 +35,21 @@ export type TemplateFileActionInput = { lstripBlocks?: boolean; }; -export function createTemplateFileActionHandler< +export async function createTemplateFileActionHandler< I extends TemplateFileActionInput = TemplateFileActionInput, >(options: { - resolveTemplateFile: (ctx: ActionContext) => Promise; + ctx: ActionContext; + resolveTemplateFile: () => Promise; integrations: ScmIntegrations; additionalTemplateFilters?: Record; additionalTemplateGlobals?: Record; -}): TemplateActionOptions['handler'] { +}) { const { resolveTemplateFile, integrations, additionalTemplateFilters, additionalTemplateGlobals: templateGlobals, + ctx, } = options; const templateFilters = { @@ -56,45 +57,43 @@ export function createTemplateFileActionHandler< ...additionalTemplateFilters, }; - return async (ctx: ActionContext) => { - const outputPath = resolveSafeChildPath( - ctx.workspacePath, - ctx.input.targetPath, - ); - - if (fs.existsSync(outputPath) && !ctx.input.replace) { - ctx.logger.info( - `File ${ctx.input.targetPath} already exists in workspace, not replacing.`, - ); - return; - } - const filePath = await resolveTemplateFile(ctx); - - const { cookiecutterCompat, values } = ctx.input; - const context = { - [cookiecutterCompat ? 'cookiecutter' : 'values']: values, - }; + const outputPath = resolveSafeChildPath( + ctx.workspacePath, + ctx.input.targetPath, + ); + if (fs.existsSync(outputPath) && !ctx.input.replace) { ctx.logger.info( - `Processing template file with input values`, - ctx.input.values, + `File ${ctx.input.targetPath} already exists in workspace, not replacing.`, ); + return; + } + const filePath = await resolveTemplateFile(); - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat, - templateFilters, - templateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); - - const contents = await fs.readFile(filePath, 'utf-8'); - const result = renderTemplate(contents, context); - await fs.ensureDir(path.dirname(outputPath)); - await fs.outputFile(outputPath, result); - - ctx.logger.info(`Template file has been written to ${outputPath}`); + const { cookiecutterCompat, values } = ctx.input; + const context = { + [cookiecutterCompat ? 'cookiecutter' : 'values']: values, }; + + ctx.logger.info( + `Processing template file with input values`, + ctx.input.values, + ); + + const renderTemplate = await SecureTemplater.loadRenderer({ + cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); + + const contents = await fs.readFile(filePath, 'utf-8'); + const result = renderTemplate(contents, context); + await fs.ensureDir(path.dirname(outputPath)); + await fs.outputFile(outputPath, result); + + ctx.logger.info(`Template file has been written to ${outputPath}`); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts index 6e2be87757..caa76fc79f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplate.ts @@ -90,13 +90,12 @@ export function createWorkspaceTemplateAction(options: { }, }, supportsDryRun: true, - handler: createTemplateActionHandler({ - resolveTemplate: async ctx => - resolveSafeChildPath( - ctx.workspacePath, - (ctx.input as any as { sourcePath: string }).sourcePath, - ), - ...options, - }), + handler: ctx => + createTemplateActionHandler({ + ctx, + resolveTemplate: async () => + resolveSafeChildPath(ctx.workspacePath, ctx.input.sourcePath), + ...options, + }), }); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts index 57a6ad881c..8b42f3d02e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/workspaceTemplateFile.ts @@ -65,13 +65,12 @@ export function createWorkspaceTemplateFileAction(options: { }, }, supportsDryRun: true, - handler: createTemplateFileActionHandler({ - resolveTemplateFile: async ctx => - resolveSafeChildPath( - ctx.workspacePath, - (ctx.input as any as { sourcePath: string }).sourcePath, - ), - ...options, - }), + handler: ctx => + createTemplateFileActionHandler({ + ctx, + resolveTemplateFile: async () => + resolveSafeChildPath(ctx.workspacePath, ctx.input.sourcePath), + ...options, + }), }); } From 7e0867a3a589fc227cfbea5ae57dbf4e0aa143ab Mon Sep 17 00:00:00 2001 From: benjdlambert Date: Tue, 29 Apr 2025 15:49:16 +0200 Subject: [PATCH 4/4] chore: ammend changeset Signed-off-by: benjdlambert --- .changeset/heavy-onions-swim.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/heavy-onions-swim.md b/.changeset/heavy-onions-swim.md index 137a2379f7..42ba056c1e 100644 --- a/.changeset/heavy-onions-swim.md +++ b/.changeset/heavy-onions-swim.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-backend': patch +'@backstage/plugin-scaffolder-backend': minor --- -Added workspace:template and workspace:template:file actions to complement respective fetch:\* actions +Added `workspace:template` and `workspace:template:file` actions to complement respective `fetch:*` actions