Merge pull request #29074 from mbenson/fetchWorkspace

Added workspace:template and workspace:template:file actions to complement respective fetch:* actions
This commit is contained in:
Ben Lambert
2025-04-29 16:05:49 +02:00
committed by GitHub
9 changed files with 1443 additions and 285 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added `workspace:template` and `workspace:template:file` actions to complement respective `fetch:*` actions
@@ -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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
const {
reader,
integrations,
additionalTemplateFilters,
additionalTemplateGlobals,
} = options;
const defaultTemplateFilters = convertFiltersToRecord(
createDefaultFilters({ integrations }),
);
return createTemplateAction<{
url: string;
targetPath?: string;
@@ -147,201 +131,26 @@ export function createFetchTemplateAction(options: {
},
},
supportsDryRun: true,
async handler(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');
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 ?? {}),
return templateDir;
},
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}`);
},
...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('//')
);
}
@@ -0,0 +1,267 @@
/*
* 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,
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';
export 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 async function createTemplateActionHandler<
I extends TemplateActionInput,
>(options: {
ctx: ActionContext<I, any, any>;
resolveTemplate: () => Promise<string>;
integrations: ScmIntegrations;
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
const {
resolveTemplate,
integrations,
additionalTemplateFilters,
additionalTemplateGlobals: templateGlobals,
ctx,
} = options;
const templateFilters = {
...convertFiltersToRecord(createDefaultFilters({ integrations })),
...additionalTemplateFilters,
};
const { outputDir, copyOnlyPatterns, renderFilename, extension } =
resolveTemplateActionSettings(ctx);
const templateDir = await resolveTemplate();
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<I extends TemplateActionInput>(
ctx: ActionContext<I, any, any>,
): {
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('//')
);
}
@@ -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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
const {
reader,
integrations,
additionalTemplateFilters,
additionalTemplateGlobals,
} = options;
const defaultTemplateFilters = convertFiltersToRecord(
createDefaultFilters({ integrations }),
);
return createTemplateAction<{
url: string;
targetPath: string;
@@ -110,63 +95,26 @@ export function createFetchTemplateFileAction(options: {
},
},
supportsDryRun: true,
async handler(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');
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,
await fetchFile({
baseUrl: ctx.templateInfo?.baseUrl,
fetchUrl: ctx.input.url,
outputPath: tmpFilePath,
token: ctx.input.token,
...options,
});
return tmpFilePath;
},
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}`);
},
...options,
}),
});
}
@@ -0,0 +1,99 @@
/*
* 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,
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 async function createTemplateFileActionHandler<
I extends TemplateFileActionInput = TemplateFileActionInput,
>(options: {
ctx: ActionContext<I, any, any>;
resolveTemplateFile: () => Promise<string>;
integrations: ScmIntegrations;
additionalTemplateFilters?: Record<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
const {
resolveTemplateFile,
integrations,
additionalTemplateFilters,
additionalTemplateGlobals: templateGlobals,
ctx,
} = options;
const templateFilters = {
...convertFiltersToRecord(createDefaultFilters({ integrations })),
...additionalTemplateFilters,
};
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();
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}`);
}
@@ -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<any, any, 'v2'>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
const mockContext = (inputPatch: Partial<WorkspaceTemplateInput> = {}) =>
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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');
});
});
});
@@ -0,0 +1,101 @@
/*
* 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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
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: ctx =>
createTemplateActionHandler({
ctx,
resolveTemplate: async () =>
resolveSafeChildPath(ctx.workspacePath, ctx.input.sourcePath),
...options,
}),
});
}
@@ -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<any, any, 'v2'>;
const mockDir = createMockDirectory();
const workspacePath = mockDir.resolve('workspace');
const mockContext = (inputPatch: Partial<WorkspaceTemplateInput> = {}) =>
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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<WorkspaceTemplateInput>;
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"]',
);
});
});
});
});
@@ -0,0 +1,76 @@
/*
* 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<string, TemplateFilter>;
additionalTemplateGlobals?: Record<string, TemplateGlobal>;
}) {
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: ctx =>
createTemplateFileActionHandler({
ctx,
resolveTemplateFile: async () =>
resolveSafeChildPath(ctx.workspacePath, ctx.input.sourcePath),
...options,
}),
});
}