From b438caf639d3f9e63d1290ad3efc903d76782d3b Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Thu, 12 Aug 2021 11:10:09 +0200 Subject: [PATCH 1/7] Add a partial template action to the builtin scaffolder actions. Closes #6794 This blends fetch:plain and fetch:template, and triggers the template engine only for files with a specific extension, .njk by default. It doesn't contain the cookiecutter-compat logic of fetch:template. Signed-off-by: Axel Hecht --- .changeset/heavy-monkeys-drum.md | 9 + .../actions/builtin/createBuiltinActions.ts | 10 +- .../scaffolder/actions/builtin/fetch/index.ts | 1 + .../actions/builtin/fetch/partial.test.ts | 223 ++++++++++++++++++ .../actions/builtin/fetch/partial.ts | 176 ++++++++++++++ 5 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 .changeset/heavy-monkeys-drum.md create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts diff --git a/.changeset/heavy-monkeys-drum.md b/.changeset/heavy-monkeys-drum.md new file mode 100644 index 0000000000..0b03020ca9 --- /dev/null +++ b/.changeset/heavy-monkeys-drum.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Add `fetch:partial` templating action. + +- For all files with extension `.njk`, apply templating logic and strip extension. The extension is configurable. +- All other files get copied. +- All output paths are subject to applying templating logic. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 5a16ef9a55..0d1b636049 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -24,7 +24,11 @@ import { } from './catalog'; import { createDebugLogAction } from './debug'; -import { createFetchPlainAction, createFetchTemplateAction } from './fetch'; +import { + createFetchPartialAction, + createFetchPlainAction, + createFetchTemplateAction, +} from './fetch'; import { createFetchCookiecutterAction } from '@backstage/plugin-scaffolder-backend-module-cookiecutter'; import { createFilesystemDeleteAction, @@ -63,6 +67,10 @@ export const createBuiltinActions = (options: { integrations, reader, }), + createFetchPartialAction({ + integrations, + reader, + }), createPublishGithubAction({ integrations, config, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts index c9a16c274e..260e85af77 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +export { createFetchPartialAction } from './partial'; export { createFetchPlainAction } from './plain'; export { createFetchTemplateAction } from './template'; export { fetchContents } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts new file mode 100644 index 0000000000..3b5728111b --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts @@ -0,0 +1,223 @@ +/* + * 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 os from 'os'; +import { join as joinPath, resolve as resolvePath } from 'path'; +import fs from 'fs-extra'; +import mockFs from 'mock-fs'; +import { getVoidLogger, UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { PassThrough } from 'stream'; +import { fetchContents } from './helpers'; +import { ActionContext, TemplateAction } from '../../types'; +import { createFetchPartialAction, FetchPartialInput } from './partial'; + +jest.mock('./helpers', () => ({ + fetchContents: jest.fn(), +})); + +const aBinaryFile = fs.readFileSync( + resolvePath( + 'src', + '../fixtures/test-nested-template/public/react-logo192.png', + ), +); + +const mockFetchContents = fetchContents as jest.MockedFunction< + typeof fetchContents +>; + +describe('fetch:partial', () => { + let action: TemplateAction; + + const workspacePath = os.tmpdir(); + const createTemporaryDirectory: jest.MockedFunction< + ActionContext['createTemporaryDirectory'] + > = jest.fn(() => + Promise.resolve( + joinPath(workspacePath, `${createTemporaryDirectory.mock.calls.length}`), + ), + ); + + const logger = getVoidLogger(); + + const mockContext = (inputPatch: Partial = {}) => ({ + baseUrl: 'base-url', + input: { + url: './skeleton', + targetPath: './target', + values: { + test: 'value', + }, + ...inputPatch, + }, + output: jest.fn(), + logStream: new PassThrough(), + logger, + workspacePath, + createTemporaryDirectory, + }); + + beforeEach(() => { + mockFs(); + + action = createFetchPartialAction({ + reader: Symbol('UrlReader') as unknown as UrlReader, + integrations: Symbol('Integrations') as unknown as ScmIntegrations, + }); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it(`returns a TemplateAction with the id 'fetch:partial'`, () => { + expect(action.id).toEqual('fetch:partial'); + }); + + describe('handler', () => { + it('throws if output directory is outside the workspace', async () => { + await expect(() => + action.handler(mockContext({ targetPath: '../' })), + ).rejects.toThrowError( + /relative path is not allowed to refer to a directory outside its parent/i, + ); + }); + + describe('with valid input', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + values: { + name: 'test-project', + count: 1234, + itemList: ['first', 'second', 'third'], + }, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + [outputPath]: { + 'empty-dir-${{ values.count }}': {}, + 'static.txt': 'static content', + '${{ values.name }}.txt': 'static content', + subdir: { + 'templated-content.txt.njk': + '${{ values.name }}: ${{ values.count }}', + }, + '.${{ values.name }}.njk': '${{ values.itemList | dump }}', + 'a-binary-file.png': aBinaryFile, + }, + }); + + return Promise.resolve(); + }); + + await action.handler(context); + }); + + it('uses fetchContents to retrieve the template content', () => { + expect(mockFetchContents).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: context.baseUrl, + fetchUrl: context.input.url, + }), + ); + }); + + 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); + }); + }); + + describe('with specified .jinja2 extension', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + extension: '.jinja2', + values: { + name: 'test-project', + count: 1234, + }, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + [outputPath]: { + '${{ values.name }}.njk': + '${{ values.name }}: ${{ values.count }}', + '${{ values.name }}.txt.jinja2': + '${{ values.name }}: ${{ values.count }}', + }, + }); + + return Promise.resolve(); + }); + + 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'); + }); + }); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts new file mode 100644 index 0000000000..2b9aecb6af --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts @@ -0,0 +1,176 @@ +/* + * 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 { resolve as resolvePath } from 'path'; +import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; +import { ScmIntegrations } from '@backstage/integration'; +import { fetchContents } from './helpers'; +import { createTemplateAction } from '../../createTemplateAction'; +import globby from 'globby'; +import nunjucks from 'nunjucks'; +import fs from 'fs-extra'; +import { isBinaryFile } from 'isbinaryfile'; + +/* + * Maximise compatibility with Jinja (and therefore fetch:template) + * using nunjucks jinja compat mode. Since this method mutates + * the global nunjucks instance, we can't enable this per-template, + * so the next best option is to explicitly enable it globally and allow + * folks to rely on jinja compatibility behaviour in fetch:template + * templates if they wish. + * + * cf. https://mozilla.github.io/nunjucks/api.html#installjinjacompat + */ +nunjucks.installJinjaCompat(); + +export type FetchPartialInput = { + url: string; + targetPath?: string; + values: any; + extension?: string; +}; + +export function createFetchPartialAction(options: { + reader: UrlReader; + integrations: ScmIntegrations; +}) { + const { reader, integrations } = options; + + return createTemplateAction({ + id: 'fetch:partial', + description: + "Downloads a skeleton, templates variables into file and directory names and content that end with the specified extension, and places the result in the workspace, or optionally in a subdirectory specified by the 'targetPath' input option.", + schema: { + input: { + type: 'object', + required: ['url'], + properties: { + url: { + title: 'Fetch URL', + description: + 'Relative path or absolute URL pointing to the directory tree to fetch', + type: 'string', + }, + targetPath: { + title: 'Target Path', + description: + 'Target path within the working directory to download the contents to. Defaults to the working directory root.', + type: 'string', + }, + values: { + title: 'Template Values', + description: 'Values to pass on to the templating engine', + type: 'object', + }, + extension: { + title: 'Extension to Process (.njk)', + description: 'Extension to use for template.', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + ctx.logger.info('Fetching template content from remote URL'); + + const workDir = await ctx.createTemporaryDirectory(); + const templateDir = resolvePath(workDir, 'template'); + + const targetPath = ctx.input.targetPath ?? './'; + const extension = ctx.input.extension ?? '.njk'; + const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); + + await fetchContents({ + reader, + integrations, + baseUrl: ctx.baseUrl, + fetchUrl: ctx.input.url, + outputPath: templateDir, + }); + + ctx.logger.info('Listing files and directories in template'); + const allEntriesInTemplate = await globby(`**/*`, { + cwd: templateDir, + dot: true, + onlyFiles: false, + markDirectories: true, + }); + + // Create a templater + const templater = nunjucks.configure({ + tags: { + // TODO(mtlewis/orkohunter): Document Why we are changing the literals? Not here, but on scaffolder docs. ADR? + variableStart: '${{', + variableEnd: '}}', + }, + // We don't want this builtin auto-escaping, since uses HTML escape sequences + // like `"` - the correct way to escape strings in our case depends on + // the file type. + autoescape: false, + }); + + const { values } = ctx.input; + const context = { + values, + }; + + ctx.logger.info( + `Processing ${allEntriesInTemplate.length} template files/directories with input values`, + ctx.input.values, + ); + + for (const location of allEntriesInTemplate) { + let outputPath = resolvePath( + outputDir, + templater.renderString(location, context), + ); + if (outputPath.endsWith(extension)) { + outputPath = outputPath.slice(0, -extension.length); + } + + if (location.endsWith('/')) { + ctx.logger.info( + `Writing directory ${location} to template output path.`, + ); + await fs.ensureDir(outputPath); + } else { + const inputFilePath = resolvePath(templateDir, location); + + if ( + !location.endsWith(extension) || + (await isBinaryFile(inputFilePath)) + ) { + ctx.logger.info( + `Copying file ${location} to template output path.`, + ); + await fs.copy(inputFilePath, outputPath); + } else { + ctx.logger.info( + `Writing file ${location} to template output path.`, + ); + const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); + await fs.outputFile( + outputPath, + templater.renderString(inputFileContents, context), + ); + } + } + } + + ctx.logger.info(`Template result written to ${outputDir}`); + }, + }); +} From 013a8742fa04621bb8f0fb0dfab9d999f8aecf1f Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Fri, 13 Aug 2021 16:16:20 +0200 Subject: [PATCH 2/7] Move partial templating loging into fetch:template Signed-off-by: Axel Hecht --- .changeset/heavy-monkeys-drum.md | 10 +- .../actions/builtin/createBuiltinActions.ts | 10 +- .../scaffolder/actions/builtin/fetch/index.ts | 1 - .../actions/builtin/fetch/partial.test.ts | 223 ------------------ .../actions/builtin/fetch/partial.ts | 176 -------------- .../actions/builtin/fetch/template.test.ts | 114 +++++++++ .../actions/builtin/fetch/template.ts | 56 ++++- 7 files changed, 167 insertions(+), 423 deletions(-) delete mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts delete mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts diff --git a/.changeset/heavy-monkeys-drum.md b/.changeset/heavy-monkeys-drum.md index 0b03020ca9..24124ad273 100644 --- a/.changeset/heavy-monkeys-drum.md +++ b/.changeset/heavy-monkeys-drum.md @@ -2,8 +2,10 @@ '@backstage/plugin-scaffolder-backend': minor --- -Add `fetch:partial` templating action. +Add partial templating to `fetch:template` action. -- For all files with extension `.njk`, apply templating logic and strip extension. The extension is configurable. -- All other files get copied. -- All output paths are subject to applying templating logic. +If an `extension` input is given, only files with that extension get their content processed. If `extension` is `true`, the `.njk` extension is used. The `extension` input is incompatible with both `cookiecutterCompat` and `copyWithoutRender`. + +All other files get copied. + +All output paths are subject to applying templating logic. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 0d1b636049..5a16ef9a55 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -24,11 +24,7 @@ import { } from './catalog'; import { createDebugLogAction } from './debug'; -import { - createFetchPartialAction, - createFetchPlainAction, - createFetchTemplateAction, -} from './fetch'; +import { createFetchPlainAction, createFetchTemplateAction } from './fetch'; import { createFetchCookiecutterAction } from '@backstage/plugin-scaffolder-backend-module-cookiecutter'; import { createFilesystemDeleteAction, @@ -67,10 +63,6 @@ export const createBuiltinActions = (options: { integrations, reader, }), - createFetchPartialAction({ - integrations, - reader, - }), createPublishGithubAction({ integrations, config, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts index 260e85af77..c9a16c274e 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -export { createFetchPartialAction } from './partial'; export { createFetchPlainAction } from './plain'; export { createFetchTemplateAction } from './template'; export { fetchContents } from './helpers'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts deleted file mode 100644 index 3b5728111b..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -/* - * 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 os from 'os'; -import { join as joinPath, resolve as resolvePath } from 'path'; -import fs from 'fs-extra'; -import mockFs from 'mock-fs'; -import { getVoidLogger, UrlReader } from '@backstage/backend-common'; -import { ScmIntegrations } from '@backstage/integration'; -import { PassThrough } from 'stream'; -import { fetchContents } from './helpers'; -import { ActionContext, TemplateAction } from '../../types'; -import { createFetchPartialAction, FetchPartialInput } from './partial'; - -jest.mock('./helpers', () => ({ - fetchContents: jest.fn(), -})); - -const aBinaryFile = fs.readFileSync( - resolvePath( - 'src', - '../fixtures/test-nested-template/public/react-logo192.png', - ), -); - -const mockFetchContents = fetchContents as jest.MockedFunction< - typeof fetchContents ->; - -describe('fetch:partial', () => { - let action: TemplateAction; - - const workspacePath = os.tmpdir(); - const createTemporaryDirectory: jest.MockedFunction< - ActionContext['createTemporaryDirectory'] - > = jest.fn(() => - Promise.resolve( - joinPath(workspacePath, `${createTemporaryDirectory.mock.calls.length}`), - ), - ); - - const logger = getVoidLogger(); - - const mockContext = (inputPatch: Partial = {}) => ({ - baseUrl: 'base-url', - input: { - url: './skeleton', - targetPath: './target', - values: { - test: 'value', - }, - ...inputPatch, - }, - output: jest.fn(), - logStream: new PassThrough(), - logger, - workspacePath, - createTemporaryDirectory, - }); - - beforeEach(() => { - mockFs(); - - action = createFetchPartialAction({ - reader: Symbol('UrlReader') as unknown as UrlReader, - integrations: Symbol('Integrations') as unknown as ScmIntegrations, - }); - }); - - afterEach(() => { - mockFs.restore(); - }); - - it(`returns a TemplateAction with the id 'fetch:partial'`, () => { - expect(action.id).toEqual('fetch:partial'); - }); - - describe('handler', () => { - it('throws if output directory is outside the workspace', async () => { - await expect(() => - action.handler(mockContext({ targetPath: '../' })), - ).rejects.toThrowError( - /relative path is not allowed to refer to a directory outside its parent/i, - ); - }); - - describe('with valid input', () => { - let context: ActionContext; - - beforeEach(async () => { - context = mockContext({ - values: { - name: 'test-project', - count: 1234, - itemList: ['first', 'second', 'third'], - }, - }); - - mockFetchContents.mockImplementation(({ outputPath }) => { - mockFs({ - [outputPath]: { - 'empty-dir-${{ values.count }}': {}, - 'static.txt': 'static content', - '${{ values.name }}.txt': 'static content', - subdir: { - 'templated-content.txt.njk': - '${{ values.name }}: ${{ values.count }}', - }, - '.${{ values.name }}.njk': '${{ values.itemList | dump }}', - 'a-binary-file.png': aBinaryFile, - }, - }); - - return Promise.resolve(); - }); - - await action.handler(context); - }); - - it('uses fetchContents to retrieve the template content', () => { - expect(mockFetchContents).toHaveBeenCalledWith( - expect.objectContaining({ - baseUrl: context.baseUrl, - fetchUrl: context.input.url, - }), - ); - }); - - 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); - }); - }); - - describe('with specified .jinja2 extension', () => { - let context: ActionContext; - - beforeEach(async () => { - context = mockContext({ - extension: '.jinja2', - values: { - name: 'test-project', - count: 1234, - }, - }); - - mockFetchContents.mockImplementation(({ outputPath }) => { - mockFs({ - [outputPath]: { - '${{ values.name }}.njk': - '${{ values.name }}: ${{ values.count }}', - '${{ values.name }}.txt.jinja2': - '${{ values.name }}: ${{ values.count }}', - }, - }); - - return Promise.resolve(); - }); - - 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'); - }); - }); - }); -}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts deleted file mode 100644 index 2b9aecb6af..0000000000 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/partial.ts +++ /dev/null @@ -1,176 +0,0 @@ -/* - * 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 { resolve as resolvePath } from 'path'; -import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; -import { ScmIntegrations } from '@backstage/integration'; -import { fetchContents } from './helpers'; -import { createTemplateAction } from '../../createTemplateAction'; -import globby from 'globby'; -import nunjucks from 'nunjucks'; -import fs from 'fs-extra'; -import { isBinaryFile } from 'isbinaryfile'; - -/* - * Maximise compatibility with Jinja (and therefore fetch:template) - * using nunjucks jinja compat mode. Since this method mutates - * the global nunjucks instance, we can't enable this per-template, - * so the next best option is to explicitly enable it globally and allow - * folks to rely on jinja compatibility behaviour in fetch:template - * templates if they wish. - * - * cf. https://mozilla.github.io/nunjucks/api.html#installjinjacompat - */ -nunjucks.installJinjaCompat(); - -export type FetchPartialInput = { - url: string; - targetPath?: string; - values: any; - extension?: string; -}; - -export function createFetchPartialAction(options: { - reader: UrlReader; - integrations: ScmIntegrations; -}) { - const { reader, integrations } = options; - - return createTemplateAction({ - id: 'fetch:partial', - description: - "Downloads a skeleton, templates variables into file and directory names and content that end with the specified extension, and places the result in the workspace, or optionally in a subdirectory specified by the 'targetPath' input option.", - schema: { - input: { - type: 'object', - required: ['url'], - properties: { - url: { - title: 'Fetch URL', - description: - 'Relative path or absolute URL pointing to the directory tree to fetch', - type: 'string', - }, - targetPath: { - title: 'Target Path', - description: - 'Target path within the working directory to download the contents to. Defaults to the working directory root.', - type: 'string', - }, - values: { - title: 'Template Values', - description: 'Values to pass on to the templating engine', - type: 'object', - }, - extension: { - title: 'Extension to Process (.njk)', - description: 'Extension to use for template.', - type: 'string', - }, - }, - }, - }, - async handler(ctx) { - ctx.logger.info('Fetching template content from remote URL'); - - const workDir = await ctx.createTemporaryDirectory(); - const templateDir = resolvePath(workDir, 'template'); - - const targetPath = ctx.input.targetPath ?? './'; - const extension = ctx.input.extension ?? '.njk'; - const outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath); - - await fetchContents({ - reader, - integrations, - baseUrl: ctx.baseUrl, - fetchUrl: ctx.input.url, - outputPath: templateDir, - }); - - ctx.logger.info('Listing files and directories in template'); - const allEntriesInTemplate = await globby(`**/*`, { - cwd: templateDir, - dot: true, - onlyFiles: false, - markDirectories: true, - }); - - // Create a templater - const templater = nunjucks.configure({ - tags: { - // TODO(mtlewis/orkohunter): Document Why we are changing the literals? Not here, but on scaffolder docs. ADR? - variableStart: '${{', - variableEnd: '}}', - }, - // We don't want this builtin auto-escaping, since uses HTML escape sequences - // like `"` - the correct way to escape strings in our case depends on - // the file type. - autoescape: false, - }); - - const { values } = ctx.input; - const context = { - values, - }; - - ctx.logger.info( - `Processing ${allEntriesInTemplate.length} template files/directories with input values`, - ctx.input.values, - ); - - for (const location of allEntriesInTemplate) { - let outputPath = resolvePath( - outputDir, - templater.renderString(location, context), - ); - if (outputPath.endsWith(extension)) { - outputPath = outputPath.slice(0, -extension.length); - } - - if (location.endsWith('/')) { - ctx.logger.info( - `Writing directory ${location} to template output path.`, - ); - await fs.ensureDir(outputPath); - } else { - const inputFilePath = resolvePath(templateDir, location); - - if ( - !location.endsWith(extension) || - (await isBinaryFile(inputFilePath)) - ) { - ctx.logger.info( - `Copying file ${location} to template output path.`, - ); - await fs.copy(inputFilePath, outputPath); - } else { - ctx.logger.info( - `Writing file ${location} to template output path.`, - ); - const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); - await fs.outputFile( - outputPath, - templater.renderString(inputFileContents, context), - ); - } - } - } - - ctx.logger.info(`Template result written to ${outputDir}`); - }, - }); -} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index aa13141f55..659fe1692a 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -292,4 +292,118 @@ describe('fetch:template', () => { ).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'], + }, + extension: true, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + [outputPath]: { + 'empty-dir-${{ values.count }}': {}, + 'static.txt': 'static content', + '${{ values.name }}.txt': 'static content', + subdir: { + 'templated-content.txt.njk': + '${{ values.name }}: ${{ values.count }}', + }, + '.${{ values.name }}.njk': '${{ values.itemList | dump }}', + 'a-binary-file.png': aBinaryFile, + }, + }); + + return Promise.resolve(); + }); + + 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); + }); + }); + + describe('with specified .jinja2 extension', () => { + let context: ActionContext; + + beforeEach(async () => { + context = mockContext({ + extension: '.jinja2', + values: { + name: 'test-project', + count: 1234, + }, + }); + + mockFetchContents.mockImplementation(({ outputPath }) => { + mockFs({ + [outputPath]: { + '${{ values.name }}.njk': '${{ values.name }}: ${{ values.count }}', + '${{ values.name }}.txt.jinja2': + '${{ values.name }}: ${{ values.count }}', + }, + }); + + return Promise.resolve(); + }); + + 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'); + }); + }); }); 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 87e67f76be..f6afa5e8c6 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -38,13 +38,21 @@ import { isBinaryFile } from 'isbinaryfile'; */ nunjucks.installJinjaCompat(); +type CookieCompatInput = { + copyWithoutRender?: string[]; + cookiecutterCompat?: boolean; +}; + +type ExtensionInput = { + extension?: string | boolean; +}; + export type FetchTemplateInput = { url: string; targetPath?: string; values: any; - copyWithoutRender?: string[]; - cookiecutterCompat?: boolean; -}; +} & CookieCompatInput & + ExtensionInput; export function createFetchTemplateAction(options: { reader: UrlReader; @@ -93,6 +101,11 @@ export function createFetchTemplateAction(options: { 'Enable features to maximise compatibility with templates built for fetch:cookiecutter', type: 'boolean', }, + extension: { + title: 'Extension to Process (.njk)', + description: 'Extension to use for templated files.', + type: ['string', 'boolean'], + }, }, }, }, @@ -114,6 +127,23 @@ export function createFetchTemplateAction(options: { ); } + if ( + ctx.input.extension && + (ctx.input.copyWithoutRender || ctx.input.cookiecutterCompat) + ) { + throw new InputError( + 'Fetch action input extension incompatible with copyWithoutRender and cookiecutterCompat', + ); + } + + let extension: string | false = false; + if (ctx.input.extension) { + extension = + typeof ctx.input.extension === 'boolean' + ? '.njk' + : ctx.input.extension; + } + await fetchContents({ reader, integrations, @@ -190,14 +220,20 @@ export function createFetchTemplateAction(options: { ); for (const location of allEntriesInTemplate) { - const shouldCopyWithoutRender = nonTemplatedEntries.has(location); + let shouldCopyWithoutRender = nonTemplatedEntries.has(location); - const outputPath = resolvePath( - outputDir, - shouldCopyWithoutRender - ? location - : templater.renderString(location, context), - ); + let localOutputPath = location; + if (extension) { + if (localOutputPath.endsWith(extension)) { + localOutputPath = localOutputPath.slice(0, -extension.length); + } else { + shouldCopyWithoutRender = true; + } + localOutputPath = templater.renderString(localOutputPath, context); + } else if (!shouldCopyWithoutRender) { + localOutputPath = templater.renderString(localOutputPath, context); + } + const outputPath = resolvePath(outputDir, localOutputPath); if (shouldCopyWithoutRender) { ctx.logger.info( From 5536c27dbb9b8e3947ec47011015e481ef13addd Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Mon, 16 Aug 2021 10:48:39 +0200 Subject: [PATCH 3/7] address review comments and test input validation Signed-off-by: Axel Hecht --- .../actions/builtin/fetch/template.test.ts | 43 +++++++++++++++++++ .../actions/builtin/fetch/template.ts | 17 +++++--- 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 659fe1692a..61b77ad679 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -105,6 +105,49 @@ describe('fetch:template', () => { ).rejects.toThrowError(/copyWithoutRender must be an array/i); }); + it('throws if copyWithoutRender is used with extension', async () => { + await expect(() => + action.handler( + mockContext({ + copyWithoutRender: ['abc'], + extension: true, + }), + ), + ).rejects.toThrowError( + /input extension incompatible with copyWithoutRender and cookiecutterCompat/, + ); + }); + + it('throws if cookiecutterCompat is used with extension', async () => { + await expect(() => + action.handler( + mockContext({ + cookiecutterCompat: true, + extension: true, + }), + ), + ).rejects.toThrowError( + /input extension incompatible with copyWithoutRender and cookiecutterCompat/, + ); + }); + + it('throws if extension string lacks a leading dot', async () => { + await expect(() => + action.handler( + mockContext({ + extension: 'njk', + }), + ), + ).rejects.toThrowError(/extension needs to start with a `.`/); + await expect(() => + action.handler( + mockContext({ + extension: '.', + }), + ), + ).rejects.toThrowError(/extension needs to start with a `.`/); + }); + describe('with valid input', () => { let context: ActionContext; 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 f6afa5e8c6..6a4fc3865b 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { resolve as resolvePath } from 'path'; +import { resolve as resolvePath, extname } from 'path'; import { resolveSafeChildPath, UrlReader } from '@backstage/backend-common'; import { InputError } from '@backstage/errors'; import { ScmIntegrations } from '@backstage/integration'; @@ -138,10 +138,15 @@ export function createFetchTemplateAction(options: { let extension: string | false = false; if (ctx.input.extension) { - extension = - typeof ctx.input.extension === 'boolean' - ? '.njk' - : ctx.input.extension; + extension = ctx.input.extension === true ? '.njk' : ctx.input.extension; + } + if ( + extension !== false && + (extension.length < 2 || !extension.startsWith('.')) + ) { + throw new InputError( + 'Fetch action input extension needs to start with a `.`', + ); } await fetchContents({ @@ -224,7 +229,7 @@ export function createFetchTemplateAction(options: { let localOutputPath = location; if (extension) { - if (localOutputPath.endsWith(extension)) { + if (extname(localOutputPath) === extension) { localOutputPath = localOutputPath.slice(0, -extension.length); } else { shouldCopyWithoutRender = true; From 7dd86e523326b513e36ef14295b305d45f475cef Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Mon, 16 Aug 2021 14:29:56 +0200 Subject: [PATCH 4/7] Prepend dot if not part of extension, clean up logic Signed-off-by: Axel Hecht --- .../actions/builtin/fetch/template.test.ts | 17 ---------- .../actions/builtin/fetch/template.ts | 34 ++++++++----------- 2 files changed, 15 insertions(+), 36 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 61b77ad679..0586f978ba 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -131,23 +131,6 @@ describe('fetch:template', () => { ); }); - it('throws if extension string lacks a leading dot', async () => { - await expect(() => - action.handler( - mockContext({ - extension: 'njk', - }), - ), - ).rejects.toThrowError(/extension needs to start with a `.`/); - await expect(() => - action.handler( - mockContext({ - extension: '.', - }), - ), - ).rejects.toThrowError(/extension needs to start with a `.`/); - }); - describe('with valid input', () => { let context: ActionContext; 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 6a4fc3865b..5c55f02f13 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -139,14 +139,9 @@ export function createFetchTemplateAction(options: { let extension: string | false = false; if (ctx.input.extension) { extension = ctx.input.extension === true ? '.njk' : ctx.input.extension; - } - if ( - extension !== false && - (extension.length < 2 || !extension.startsWith('.')) - ) { - throw new InputError( - 'Fetch action input extension needs to start with a `.`', - ); + if (!extension.startsWith('.')) { + extension = `.${extension}`; + } } await fetchContents({ @@ -225,24 +220,25 @@ export function createFetchTemplateAction(options: { ); for (const location of allEntriesInTemplate) { - let shouldCopyWithoutRender = nonTemplatedEntries.has(location); + let renderFilename = !nonTemplatedEntries.has(location); + let renderContents = renderFilename; let localOutputPath = location; if (extension) { - if (extname(localOutputPath) === extension) { + renderFilename = true; + renderContents = extname(localOutputPath) === extension; + if (renderContents) { localOutputPath = localOutputPath.slice(0, -extension.length); - } else { - shouldCopyWithoutRender = true; } - localOutputPath = templater.renderString(localOutputPath, context); - } else if (!shouldCopyWithoutRender) { + } + if (renderFilename) { localOutputPath = templater.renderString(localOutputPath, context); } const outputPath = resolvePath(outputDir, localOutputPath); - if (shouldCopyWithoutRender) { + if (!renderContents) { ctx.logger.info( - `Copying file/directory ${location} without processing since it matches a pattern in "copyWithoutRender".`, + `Copying file/directory ${location} without processing.`, ); } @@ -266,9 +262,9 @@ export function createFetchTemplateAction(options: { const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); await fs.outputFile( outputPath, - shouldCopyWithoutRender - ? inputFileContents - : templater.renderString(inputFileContents, context), + renderContents + ? templater.renderString(inputFileContents, context) + : inputFileContents, ); } } From 8fb45d6e37f4b395947fdc77caec390a67774251 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Mon, 16 Aug 2021 18:45:13 +0200 Subject: [PATCH 5/7] Add a jinja2 template to the njk test path Signed-off-by: Axel Hecht --- .../src/scaffolder/actions/builtin/fetch/template.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 0586f978ba..7a094831ea 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -338,6 +338,8 @@ describe('fetch:template', () => { '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 }}', @@ -365,6 +367,12 @@ describe('fetch:template', () => { ).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( From 87814d231f0dbe84a1d1b8e94440976532a8876c Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Wed, 18 Aug 2021 18:59:51 +0200 Subject: [PATCH 6/7] Fix review comments Signed-off-by: Axel Hecht --- .changeset/heavy-monkeys-drum.md | 4 ++-- .../actions/builtin/fetch/template.test.ts | 8 +++---- .../actions/builtin/fetch/template.ts | 24 ++++++++++++------- 3 files changed, 21 insertions(+), 15 deletions(-) diff --git a/.changeset/heavy-monkeys-drum.md b/.changeset/heavy-monkeys-drum.md index 24124ad273..a58c3f291d 100644 --- a/.changeset/heavy-monkeys-drum.md +++ b/.changeset/heavy-monkeys-drum.md @@ -1,10 +1,10 @@ --- -'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-backend': patch --- Add partial templating to `fetch:template` action. -If an `extension` input is given, only files with that extension get their content processed. If `extension` is `true`, the `.njk` extension is used. The `extension` input is incompatible with both `cookiecutterCompat` and `copyWithoutRender`. +If an `templateFileExtension` input is given, only files with that extension get their content processed. If `templateFileExtension` is `true`, the `.njk` extension is used. The `templateFileExtension` input is incompatible with both `cookiecutterCompat` and `copyWithoutRender`. All other files get copied. diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts index 7a094831ea..752212606f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.test.ts @@ -110,7 +110,7 @@ describe('fetch:template', () => { action.handler( mockContext({ copyWithoutRender: ['abc'], - extension: true, + templateFileExtension: true, }), ), ).rejects.toThrowError( @@ -123,7 +123,7 @@ describe('fetch:template', () => { action.handler( mockContext({ cookiecutterCompat: true, - extension: true, + templateFileExtension: true, }), ), ).rejects.toThrowError( @@ -329,7 +329,7 @@ describe('fetch:template', () => { count: 1234, itemList: ['first', 'second', 'third'], }, - extension: true, + templateFileExtension: true, }); mockFetchContents.mockImplementation(({ outputPath }) => { @@ -406,7 +406,7 @@ describe('fetch:template', () => { beforeEach(async () => { context = mockContext({ - extension: '.jinja2', + templateFileExtension: '.jinja2', values: { name: 'test-project', count: 1234, 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 5c55f02f13..316e032c00 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -44,7 +44,7 @@ type CookieCompatInput = { }; type ExtensionInput = { - extension?: string | boolean; + templateFileExtension?: string | boolean; }; export type FetchTemplateInput = { @@ -101,9 +101,10 @@ export function createFetchTemplateAction(options: { 'Enable features to maximise compatibility with templates built for fetch:cookiecutter', type: 'boolean', }, - extension: { - title: 'Extension to Process (.njk)', - description: 'Extension to use for templated files.', + templateFileExtension: { + title: 'Template File Extension', + description: + 'If set, only files with the given extension will be templated. If set to `true`, the default extension `.njk` is used.', type: ['string', 'boolean'], }, }, @@ -128,7 +129,7 @@ export function createFetchTemplateAction(options: { } if ( - ctx.input.extension && + ctx.input.templateFileExtension && (ctx.input.copyWithoutRender || ctx.input.cookiecutterCompat) ) { throw new InputError( @@ -137,8 +138,11 @@ export function createFetchTemplateAction(options: { } let extension: string | false = false; - if (ctx.input.extension) { - extension = ctx.input.extension === true ? '.njk' : ctx.input.extension; + if (ctx.input.templateFileExtension) { + extension = + ctx.input.templateFileExtension === true + ? '.njk' + : ctx.input.templateFileExtension; if (!extension.startsWith('.')) { extension = `.${extension}`; } @@ -220,8 +224,8 @@ export function createFetchTemplateAction(options: { ); for (const location of allEntriesInTemplate) { - let renderFilename = !nonTemplatedEntries.has(location); - let renderContents = renderFilename; + let renderFilename: boolean; + let renderContents: boolean; let localOutputPath = location; if (extension) { @@ -230,6 +234,8 @@ export function createFetchTemplateAction(options: { if (renderContents) { localOutputPath = localOutputPath.slice(0, -extension.length); } + } else { + renderFilename = renderContents = !nonTemplatedEntries.has(location); } if (renderFilename) { localOutputPath = templater.renderString(localOutputPath, context); From be944d6c5ca478675d3a8c9cffeeb0522c011684 Mon Sep 17 00:00:00 2001 From: Axel Hecht Date: Wed, 18 Aug 2021 19:53:39 +0200 Subject: [PATCH 7/7] Silence logger a bit Signed-off-by: Axel Hecht --- .../src/scaffolder/actions/builtin/fetch/template.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 316e032c00..425623d154 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/template.ts @@ -242,7 +242,7 @@ export function createFetchTemplateAction(options: { } const outputPath = resolvePath(outputDir, localOutputPath); - if (!renderContents) { + if (!renderContents && !extension) { ctx.logger.info( `Copying file/directory ${location} without processing.`, );