Merge pull request #6322 from backstage/mob/nunjucks-renderer
scaffolder: Add a node-based templating action
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add new `fetch:template` action which handles the same responsibilities as `fetch:cookiecutter` without the external dependency on `cookiecutter`. For information on migrating from `fetch:cookiecutter` to `fetch:template`, see the [migration guide](https://backstage.io/docs/features/software-templates/builtin-actions#migrating-from-fetch-cookiecutter-to-fetch-template) in the docs.
|
||||
@@ -169,6 +169,7 @@ nohoist
|
||||
nonces
|
||||
noop
|
||||
npm
|
||||
nunjucks
|
||||
nvarchar
|
||||
nvm
|
||||
OAuth
|
||||
|
||||
@@ -654,7 +654,7 @@ spec:
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
action: fetch:cookiecutter
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
|
||||
@@ -51,7 +51,7 @@ spec:
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
action: fetch:cookiecutter
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
|
||||
@@ -14,3 +14,54 @@ Azure, GitLab and Bitbucket.
|
||||
A list of all registered actions can be found under `/create/actions`. For local
|
||||
development you should be able to reach them at
|
||||
`http://localhost:3000/create/actions`.
|
||||
|
||||
### Migrating from `fetch:cookiecutter` to `fetch:template`
|
||||
|
||||
The `fetch:template` action is a new action with a similar API to
|
||||
`fetch:cookiecutter` but no dependency on `cookiecutter`. There are two options
|
||||
for migrating templates that use `fetch:cookiecutter` to use `fetch:template`:
|
||||
|
||||
#### Using `cookiecutterCompat` mode
|
||||
|
||||
The new `fetch:template` action has a `cookiecutterCompat` flag which should
|
||||
allow most templates built for `fetch:cookiecutter` to work without any changes.
|
||||
|
||||
1. Update action name in `template.yaml`. The name should be changed from
|
||||
`fetch:cookiecutter` to `fetch:template`.
|
||||
2. Set `cookiecutterCompat` to `true` in the `fetch:template` step input in
|
||||
`template.yaml`.
|
||||
|
||||
```diff
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
- action: fetch:cookiecutter
|
||||
+ action: fetch:template
|
||||
input:
|
||||
url: ./skeleton
|
||||
+ cookiecutterCompat: true
|
||||
values:
|
||||
```
|
||||
|
||||
#### Manual migration
|
||||
|
||||
If you prefer, you can manually migrate your templates to avoid the need for
|
||||
enabling cookiecutter compatibility mode, which will result in slightly less
|
||||
verbose template variables expressions.
|
||||
|
||||
1. Update action name in `template.yaml`. The name should be changed from
|
||||
`fetch:cookiecutter` to `fetch:template`.
|
||||
2. Update variable syntax in file names and content. `fetch:cookiecutter`
|
||||
expects variables to be enclosed in `{{` `}}` and prefixed with
|
||||
`cookiecutter.`, while `fetch:template` expects variables to be enclosed in
|
||||
`${{` `}}` and prefixed with `values.`. For example, a reference to variable
|
||||
`myInputVariable` would need to be migrated from
|
||||
`{{ cookiecutter.myInputVariable }}` to `${{ values.myInputVariable }}`.
|
||||
3. Replace uses of `jsonify` with `dump`. The
|
||||
[`jsonify` filter](https://cookiecutter.readthedocs.io/en/latest/advanced/template_extensions.html#jsonify-extension)
|
||||
is built in to `cookiecutter`, and is not available by default when using
|
||||
`fetch:template`. The
|
||||
[`dump` filter](https://mozilla.github.io/nunjucks/templating.html#dump) is
|
||||
the equivalent filter in nunjucks, so an expression like
|
||||
`{{ cookiecutter.myAwesomeList | jsonify }}` should be migrated to
|
||||
`${{ values.myAwesomeList | dump }}`.
|
||||
|
||||
@@ -62,7 +62,7 @@ spec:
|
||||
steps:
|
||||
- id: fetch-base
|
||||
name: Fetch Base
|
||||
action: fetch:cookiecutter
|
||||
action: fetch:template
|
||||
input:
|
||||
url: ./template
|
||||
values:
|
||||
@@ -289,8 +289,8 @@ template. These follow the same standard format:
|
||||
- id: fetch-base # A unique id for the step
|
||||
name: Fetch Base # A title displayed in the frontend
|
||||
if: '{{ parameters.name }}' # Optional condition, skip the step if not truthy
|
||||
action: fetch:cookiecutter # an action to call
|
||||
input: # input that is passed as arguments to the action handler
|
||||
action: fetch:template # An action to call
|
||||
input: # Input that is passed as arguments to the action handler
|
||||
url: ./template
|
||||
values:
|
||||
name: '{{ parameters.name }}'
|
||||
@@ -317,20 +317,20 @@ output:
|
||||
|
||||
### The templating syntax
|
||||
|
||||
You might have noticed in the examples that there are `{{ }}`, and these are a
|
||||
`handlebars` templates for linking and glueing all these different parts of
|
||||
`yaml` together. All the form inputs from the `parameters` section, when passed
|
||||
to the steps will be available by using the template syntax
|
||||
`{{ parameters.something }}`. This is great for passing the values from the form
|
||||
into different steps and reusing these input variables. To pass arrays or
|
||||
objects use the syntax `{{ json paramaters.something }}` where
|
||||
`paramaters.something` is of type `object` or `array` in the `jsonSchema`, such
|
||||
as the `nicknames` parameter in the previous example.
|
||||
You might have noticed variables wrapped in `{{ }}` in the examples. These are
|
||||
`handlebars` template strings for linking and gluing the different parts of the
|
||||
template together. All the form inputs from the `parameters` section will be
|
||||
available by using this template syntax (for example,
|
||||
`{{ parameters.firstName }}` inserts the value of `firstName` from the
|
||||
parameters). This is great for passing the values from the form into different
|
||||
steps and reusing these input variables. To pass arrays or objects use the
|
||||
`json` custom [helper](https://handlebarsjs.com/guide/expressions.html#helpers).
|
||||
For example, `{{ json parameters.nicknames }}` will insert the result of calling
|
||||
`JSON.stringify` on the value of the `nicknames` parameter.
|
||||
|
||||
As you can see above in the `Outputs` section, `actions` and `steps` can also
|
||||
output things. So you can grab that output by using
|
||||
`steps.$stepId.output.$property`.
|
||||
output things. You can grab that output using `steps.$stepId.output.$property`.
|
||||
|
||||
You can read more about all the `inputs` and `outputs` defined in the actions in
|
||||
code part of the `JSONSchema` or you can read more about our built in ones
|
||||
code part of the `JSONSchema`, or you can read more about our built in ones
|
||||
[here](./builtin-actions.md).
|
||||
|
||||
@@ -74,6 +74,12 @@ export function createFetchPlainAction(options: {
|
||||
integrations: ScmIntegrations;
|
||||
}): TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
export function createFetchTemplateAction(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
}): TemplateAction<any>;
|
||||
|
||||
// @public (undocumented)
|
||||
export const createFilesystemDeleteAction: () => TemplateAction<any>;
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
@@ -51,12 +51,14 @@
|
||||
"globby": "^11.0.0",
|
||||
"handlebars": "^4.7.6",
|
||||
"helmet": "^4.0.0",
|
||||
"isbinaryfile": "^4.0.8",
|
||||
"isomorphic-git": "^1.8.0",
|
||||
"jsonschema": "^1.2.6",
|
||||
"knex": "^0.95.1",
|
||||
"lodash": "^4.17.21",
|
||||
"luxon": "^1.26.0",
|
||||
"morgan": "^1.10.0",
|
||||
"nunjucks": "^3.2.3",
|
||||
"octokit-plugin-create-pull-request": "^3.9.3",
|
||||
"uuid": "^8.2.0",
|
||||
"winston": "^3.2.1",
|
||||
@@ -69,6 +71,7 @@
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/git-url-parse": "^9.0.0",
|
||||
"@types/mock-fs": "^4.13.0",
|
||||
"@types/nunjucks": "^3.1.4",
|
||||
"@types/supertest": "^2.0.8",
|
||||
"jest-when": "^3.1.0",
|
||||
"mock-fs": "^4.13.0",
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
} from './catalog';
|
||||
|
||||
import { createDebugLogAction } from './debug';
|
||||
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
|
||||
import {
|
||||
createFetchCookiecutterAction,
|
||||
createFetchPlainAction,
|
||||
createFetchTemplateAction,
|
||||
} from './fetch';
|
||||
import {
|
||||
createFilesystemDeleteAction,
|
||||
createFilesystemRenameAction,
|
||||
@@ -62,6 +66,10 @@ export const createBuiltinActions = (options: {
|
||||
integrations,
|
||||
containerRunner,
|
||||
}),
|
||||
createFetchTemplateAction({
|
||||
integrations,
|
||||
reader,
|
||||
}),
|
||||
createPublishGithubAction({
|
||||
integrations,
|
||||
config,
|
||||
|
||||
@@ -26,7 +26,7 @@ export function createDebugLogAction() {
|
||||
return createTemplateAction<{ message?: string; listWorkspace?: boolean }>({
|
||||
id: 'debug:log',
|
||||
description:
|
||||
'Writes a message into the log or list all files in the workspace.',
|
||||
'Writes a message into the log or lists all files in the workspace.',
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
|
||||
@@ -136,7 +136,7 @@ export function createFetchCookiecutterAction(options: {
|
||||
}>({
|
||||
id: 'fetch:cookiecutter',
|
||||
description:
|
||||
'Downloads a template from the given URL into the workspace, and runs cookiecutter on it.',
|
||||
"Downloads a template from the given URL into the workspace, and runs cookiecutter on it. This action is deprecated in favor of 'fetch:template'. See https://backstage.io/docs/features/software-templates/builtin-actions#migrating-from-fetch-cookiecutter-to-fetch-template for more details.",
|
||||
schema: {
|
||||
input: {
|
||||
type: 'object',
|
||||
|
||||
@@ -16,4 +16,5 @@
|
||||
|
||||
export { createFetchPlainAction } from './plain';
|
||||
export { createFetchCookiecutterAction } from './cookiecutter';
|
||||
export { createFetchTemplateAction } from './template';
|
||||
export { fetchContents } from './helpers';
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/*
|
||||
* 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 { createFetchTemplateAction, FetchTemplateInput } from './template';
|
||||
|
||||
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:template', () => {
|
||||
let action: TemplateAction<any>;
|
||||
|
||||
const workspacePath = os.tmpdir();
|
||||
const createTemporaryDirectory: jest.MockedFunction<
|
||||
ActionContext<FetchTemplateInput>['createTemporaryDirectory']
|
||||
> = jest.fn(() =>
|
||||
Promise.resolve(
|
||||
joinPath(workspacePath, `${createTemporaryDirectory.mock.calls.length}`),
|
||||
),
|
||||
);
|
||||
|
||||
const logger = getVoidLogger();
|
||||
|
||||
const mockContext = (inputPatch: Partial<FetchTemplateInput> = {}) => ({
|
||||
baseUrl: 'base-url',
|
||||
input: {
|
||||
url: './skeleton',
|
||||
targetPath: './target',
|
||||
values: {
|
||||
test: 'value',
|
||||
},
|
||||
...inputPatch,
|
||||
},
|
||||
output: jest.fn(),
|
||||
logStream: new PassThrough(),
|
||||
logger,
|
||||
workspacePath,
|
||||
createTemporaryDirectory,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs();
|
||||
|
||||
action = createFetchTemplateAction({
|
||||
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:template'`, () => {
|
||||
expect(action.id).toEqual('fetch:template');
|
||||
});
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if copyWithoutRender parameter is not an array', async () => {
|
||||
await expect(() =>
|
||||
action.handler(
|
||||
mockContext({ copyWithoutRender: ('abc' as unknown) as string[] }),
|
||||
),
|
||||
).rejects.toThrowError(/copyWithoutRender must be an array/i);
|
||||
});
|
||||
|
||||
describe('with valid input', () => {
|
||||
let context: ActionContext<FetchTemplateInput>;
|
||||
|
||||
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':
|
||||
'${{ values.name }}: ${{ values.count }}',
|
||||
},
|
||||
'.${{ values.name }}': '${{ 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('copyWithoutRender', () => {
|
||||
let context: ActionContext<FetchTemplateInput>;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = mockContext({
|
||||
values: {
|
||||
name: 'test-project',
|
||||
count: 1234,
|
||||
},
|
||||
copyWithoutRender: ['.unprocessed'],
|
||||
});
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
[outputPath]: {
|
||||
processed: {
|
||||
'templated-content-${{ values.name }}.txt':
|
||||
'${{ values.count }}',
|
||||
},
|
||||
'.unprocessed': {
|
||||
'templated-content-${{ values.name }}.txt':
|
||||
'${{ values.count }}',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await action.handler(context);
|
||||
});
|
||||
|
||||
it('ignores template syntax in files matched in copyWithoutRender', async () => {
|
||||
await expect(
|
||||
fs.readFile(
|
||||
`${workspacePath}/target/.unprocessed/templated-content-\${{ values.name }}.txt`,
|
||||
'utf-8',
|
||||
),
|
||||
).resolves.toEqual('${{ values.count }}');
|
||||
});
|
||||
|
||||
it('processes files not matched in copyWithoutRender', async () => {
|
||||
await expect(
|
||||
fs.readFile(
|
||||
`${workspacePath}/target/processed/templated-content-test-project.txt`,
|
||||
'utf-8',
|
||||
),
|
||||
).resolves.toEqual('1234');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookiecutter compatibility mode', () => {
|
||||
let context: ActionContext<FetchTemplateInput>;
|
||||
|
||||
beforeEach(async () => {
|
||||
context = mockContext({
|
||||
values: {
|
||||
name: 'test-project',
|
||||
count: 1234,
|
||||
itemList: ['first', 'second', 'third'],
|
||||
},
|
||||
cookiecutterCompat: true,
|
||||
});
|
||||
|
||||
mockFetchContents.mockImplementation(({ outputPath }) => {
|
||||
mockFs({
|
||||
[outputPath]: {
|
||||
'{{ cookiecutter.name }}.txt': 'static content',
|
||||
subdir: {
|
||||
'templated-content.txt':
|
||||
'{{ cookiecutter.name }}: {{ cookiecutter.count }}',
|
||||
},
|
||||
'{{ cookiecutter.name }}.json':
|
||||
'{{ cookiecutter.itemList | jsonify }}',
|
||||
},
|
||||
});
|
||||
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
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"]');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* 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 { InputError } from '@backstage/errors';
|
||||
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 cookiecutter)
|
||||
* using nunjucks jinja compat mode. Since this method mutates
|
||||
* the global nunjucks instance, we can't enable this per-template,
|
||||
* or only for templates with cookiecutter compat enabled, 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 FetchTemplateInput = {
|
||||
url: string;
|
||||
targetPath?: string;
|
||||
values: any;
|
||||
copyWithoutRender?: string[];
|
||||
cookiecutterCompat?: boolean;
|
||||
};
|
||||
|
||||
export function createFetchTemplateAction(options: {
|
||||
reader: UrlReader;
|
||||
integrations: ScmIntegrations;
|
||||
}) {
|
||||
const { reader, integrations } = options;
|
||||
|
||||
return createTemplateAction<FetchTemplateInput>({
|
||||
id: 'fetch:template',
|
||||
description:
|
||||
"Downloads a skeleton, templates variables into file and directory names and content, 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',
|
||||
},
|
||||
copyWithoutRender: {
|
||||
title: 'Copy Without Render',
|
||||
description:
|
||||
'An array of glob patterns. Any files or directories which match are copied without being processed as templates.',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
cookiecutterCompat: {
|
||||
title: 'Cookiecutter compatibility mode',
|
||||
description:
|
||||
'Enable features to maximise compatibility with templates built for fetch:cookiecutter',
|
||||
type: 'boolean',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 outputDir = resolveSafeChildPath(ctx.workspacePath, targetPath);
|
||||
|
||||
if (
|
||||
ctx.input.copyWithoutRender &&
|
||||
!Array.isArray(ctx.input.copyWithoutRender)
|
||||
) {
|
||||
throw new InputError(
|
||||
'Fetch action input copyWithoutRender must be an Array',
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const nonTemplatedEntries = new Set(
|
||||
(
|
||||
await Promise.all(
|
||||
(ctx.input.copyWithoutRender || []).map(pattern =>
|
||||
globby(pattern, {
|
||||
cwd: templateDir,
|
||||
dot: true,
|
||||
onlyFiles: false,
|
||||
markDirectories: true,
|
||||
}),
|
||||
),
|
||||
)
|
||||
).flat(),
|
||||
);
|
||||
|
||||
// Create a templater
|
||||
const templater = nunjucks.configure({
|
||||
...(ctx.input.cookiecutterCompat
|
||||
? {}
|
||||
: {
|
||||
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,
|
||||
});
|
||||
|
||||
if (ctx.input.cookiecutterCompat) {
|
||||
// The "jsonify" filter built into cookiecutter is common
|
||||
// in fetch:cookiecutter templates, so when compat mode
|
||||
// is enabled we alias the "dump" filter from nunjucks as
|
||||
// jsonify. Dump accepts an optional `spaces` parameter
|
||||
// which enables indented output, but when this parameter
|
||||
// is not supplied it works identically to jsonify.
|
||||
//
|
||||
// cf. https://cookiecutter.readthedocs.io/en/latest/advanced/template_extensions.html?highlight=jsonify#jsonify-extension
|
||||
// cf. https://mozilla.github.io/nunjucks/templating.html#dump
|
||||
templater.addFilter('jsonify', templater.getFilter('dump'));
|
||||
}
|
||||
|
||||
// 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,
|
||||
);
|
||||
|
||||
for (const location of allEntriesInTemplate) {
|
||||
const shouldCopyWithoutRender = nonTemplatedEntries.has(location);
|
||||
|
||||
const outputPath = resolvePath(
|
||||
outputDir,
|
||||
shouldCopyWithoutRender
|
||||
? location
|
||||
: templater.renderString(location, context),
|
||||
);
|
||||
|
||||
if (shouldCopyWithoutRender) {
|
||||
ctx.logger.info(
|
||||
`Copying file/directory ${location} without processing since it matches a pattern in "copyWithoutRender".`,
|
||||
);
|
||||
}
|
||||
|
||||
if (location.endsWith('/')) {
|
||||
ctx.logger.info(
|
||||
`Writing directory ${location} to template output path.`,
|
||||
);
|
||||
await fs.ensureDir(outputPath);
|
||||
} else {
|
||||
const inputFilePath = resolvePath(templateDir, location);
|
||||
|
||||
if (await isBinaryFile(inputFilePath)) {
|
||||
ctx.logger.info(
|
||||
`Copying binary 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,
|
||||
shouldCopyWithoutRender
|
||||
? inputFileContents
|
||||
: templater.renderString(inputFileContents, context),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.logger.info(`Template result written to ${outputDir}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -6255,6 +6255,11 @@
|
||||
resolved "https://registry.npmjs.org/@types/npmlog/-/npmlog-4.1.2.tgz#d070fe6a6b78755d1092a3dc492d34c3d8f871c4"
|
||||
integrity sha512-4QQmOF5KlwfxJ5IGXFIudkeLCdMABz03RcUXu+LCb24zmln8QW6aDjuGl4d4XPVLf2j+FnjelHTP7dvceAFbhA==
|
||||
|
||||
"@types/nunjucks@^3.1.4":
|
||||
version "3.1.4"
|
||||
resolved "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.1.4.tgz#2f80e2fa5e7982b2131201d0b4474ab4d03d9de1"
|
||||
integrity sha512-cR65PLlHKW/qxxj840dbNb3ICO+iAVQzaNKJ8TcKOVKFi+QcAkhw9SCY8VFAyU41SmJMs+2nrIN2JGhX+jYb7A==
|
||||
|
||||
"@types/oauth@*":
|
||||
version "0.9.1"
|
||||
resolved "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.1.tgz#e17221e7f7936b0459ae7d006255dff61adca305"
|
||||
@@ -7143,6 +7148,11 @@ JSONStream@^1.0.4:
|
||||
jsonparse "^1.2.0"
|
||||
through ">=2.2.7 <3"
|
||||
|
||||
a-sync-waterfall@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz#75b6b6aa72598b497a125e7a2770f14f4c8a1fa7"
|
||||
integrity sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==
|
||||
|
||||
abab@^2.0.0, abab@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.npmjs.org/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
|
||||
@@ -7863,7 +7873,7 @@ arrify@^2.0.0, arrify@^2.0.1:
|
||||
resolved "https://registry.npmjs.org/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
|
||||
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
|
||||
|
||||
asap@^2.0.0, asap@~2.0.3:
|
||||
asap@^2.0.0, asap@^2.0.3, asap@~2.0.3:
|
||||
version "2.0.6"
|
||||
resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46"
|
||||
integrity sha1-5QNHYR1+aQlDIIu9r+vLwvuGbUY=
|
||||
@@ -16006,6 +16016,11 @@ isarray@^2.0.5:
|
||||
resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723"
|
||||
integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==
|
||||
|
||||
isbinaryfile@^4.0.8:
|
||||
version "4.0.8"
|
||||
resolved "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.8.tgz#5d34b94865bd4946633ecc78a026fc76c5b11fcf"
|
||||
integrity sha512-53h6XFniq77YdW+spoRrebh0mnmTxRPTlcuIArO57lmMdq4uBKFKaeTjnb92oYWrSn/LVL+LT+Hap2tFQj8V+w==
|
||||
|
||||
isexe@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
|
||||
@@ -19552,6 +19567,15 @@ number-is-nan@^1.0.0:
|
||||
resolved "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz#097b602b53422a522c1afb8790318336941a011d"
|
||||
integrity sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=
|
||||
|
||||
nunjucks@^3.2.3:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.3.tgz#1b33615247290e94e28263b5d855ece765648a31"
|
||||
integrity sha512-psb6xjLj47+fE76JdZwskvwG4MYsQKXUtMsPh6U0YMvmyjRtKRFcxnlXGWglNybtNTNVmGdp94K62/+NjF5FDQ==
|
||||
dependencies:
|
||||
a-sync-waterfall "^1.0.0"
|
||||
asap "^2.0.3"
|
||||
commander "^5.1.0"
|
||||
|
||||
nwsapi@^2.0.7, nwsapi@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.0.tgz#204879a9e3d068ff2a55139c2c772780681a38b7"
|
||||
|
||||
Reference in New Issue
Block a user