feat(scaffolder): add fs:delete custom step
Signed-off-by: Andrea Falzetti <afalzetti@gmail.com>
This commit is contained in:
@@ -24,6 +24,10 @@ import {
|
||||
} from './catalog';
|
||||
import { createDebugLogAction } from './debug';
|
||||
import { createFetchCookiecutterAction, createFetchPlainAction } from './fetch';
|
||||
import {
|
||||
createFilesystemDeleteAction,
|
||||
createFilesystemRenameAction,
|
||||
} from './filesystem';
|
||||
import {
|
||||
createPublishAzureAction,
|
||||
createPublishBitbucketAction,
|
||||
@@ -68,5 +72,7 @@ export const createBuiltinActions = (options: {
|
||||
createDebugLogAction(),
|
||||
createCatalogRegisterAction({ catalogClient, integrations }),
|
||||
createCatalogWriteAction(),
|
||||
createFilesystemDeleteAction(),
|
||||
createFilesystemRenameAction(),
|
||||
];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* 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 * as os from 'os';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { createFilesystemDeleteAction } from './delete';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { PassThrough } from 'stream';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
const workspacePath = resolvePath(root, 'my-workspace');
|
||||
|
||||
describe('fs:delete', () => {
|
||||
const action = createFilesystemDeleteAction();
|
||||
|
||||
const mockContext = {
|
||||
input: {
|
||||
files: ['unit-test-a.js', 'unit-test-b.js'],
|
||||
},
|
||||
workspacePath,
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
mockFs({
|
||||
[workspacePath]: {
|
||||
'unit-test-a.js': 'hello',
|
||||
'unit-test-b.js': 'world',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should throw an error when files is not an array', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: undefined },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: {} },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: '' },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: null },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
});
|
||||
|
||||
it('should throw when file name is not relative to the workspace', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['/foo/../../../index.js'] },
|
||||
}),
|
||||
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['../../../index.js'] },
|
||||
}),
|
||||
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
|
||||
});
|
||||
|
||||
it('should call fs.rm with the correct values', async () => {
|
||||
const files = ['unit-test-a.js', 'unit-test-b.js'];
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
files.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { createTemplateAction } from '../../createTemplateAction';
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
export const createFilesystemDeleteAction = () => {
|
||||
return createTemplateAction<{ files: string[] }>({
|
||||
id: 'fs:delete',
|
||||
schema: {
|
||||
input: {
|
||||
required: ['files'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
files: {
|
||||
title: 'Files',
|
||||
description: 'A list of files or directories that will be deleted',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
}
|
||||
|
||||
for (const file of ctx.input.files) {
|
||||
const filepath = resolveSafeChildPath(ctx.workspacePath, file);
|
||||
|
||||
try {
|
||||
await fs.remove(filepath);
|
||||
ctx.logger.info(`File ${filepath} deleted successfully`);
|
||||
} catch (err) {
|
||||
ctx.logger.error(`Failed to delete file ${filepath}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export { createFilesystemDeleteAction } from './delete';
|
||||
export { createFilesystemRenameAction } from './rename';
|
||||
@@ -0,0 +1,206 @@
|
||||
/*
|
||||
* 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 * as os from 'os';
|
||||
import mockFs from 'mock-fs';
|
||||
import { resolve as resolvePath } from 'path';
|
||||
import { createFilesystemRenameAction } from './rename';
|
||||
import { getVoidLogger } from '@backstage/backend-common';
|
||||
import { PassThrough } from 'stream';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
const root = os.platform() === 'win32' ? 'C:\\rootDir' : '/rootDir';
|
||||
const workspacePath = resolvePath(root, 'my-workspace');
|
||||
|
||||
describe('fs:rename', () => {
|
||||
const action = createFilesystemRenameAction();
|
||||
|
||||
const mockInputFiles = [
|
||||
{
|
||||
from: 'unit-test-a.js',
|
||||
to: 'new-a.js',
|
||||
},
|
||||
{
|
||||
from: 'unit-test-b.js',
|
||||
to: 'new-b.js',
|
||||
},
|
||||
];
|
||||
const mockContext = {
|
||||
input: {
|
||||
files: mockInputFiles,
|
||||
},
|
||||
workspacePath,
|
||||
logger: getVoidLogger(),
|
||||
logStream: new PassThrough(),
|
||||
output: jest.fn(),
|
||||
createTemporaryDirectory: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
|
||||
mockFs({
|
||||
[workspacePath]: {
|
||||
'unit-test-a.js': 'hello',
|
||||
'unit-test-b.js': 'world',
|
||||
'unit-test-c.js': 'i will be overwritten :-(',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
});
|
||||
|
||||
it('should throw an error when files is not an array', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: undefined },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: {} },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: '' },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: null },
|
||||
}),
|
||||
).rejects.toThrow(/files must be an Array/);
|
||||
});
|
||||
|
||||
it('should throw an error when files have missing from/to', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: ['old.md'] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: 'old.md' }] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ to: 'new.md' }] },
|
||||
}),
|
||||
).rejects.toThrow(/each file must have a from and to property/);
|
||||
});
|
||||
|
||||
it('should throw when file name is not relative to the workspace', async () => {
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: 'index.js', to: '/core/../../../index.js' }] },
|
||||
}),
|
||||
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: { files: [{ from: '/core/../../../index.js', to: 'index.js' }] },
|
||||
}),
|
||||
).rejects.toThrow(/Relative path is not allowed to refer to a directory outside its parent/);
|
||||
});
|
||||
|
||||
it('should throw is trying to override by mistake', async () => {
|
||||
const destFile = 'unit-test-c.js';
|
||||
const filePath = resolvePath(workspacePath, destFile);
|
||||
const beforeContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
await expect(
|
||||
action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
files: [
|
||||
{
|
||||
from: 'unit-test-a.js',
|
||||
to: 'unit-test-c.js',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/dest already exists/);
|
||||
|
||||
const afterContent = fs.readFileSync(filePath, 'utf-8');
|
||||
|
||||
expect(beforeContent).toEqual(afterContent);
|
||||
});
|
||||
|
||||
it('should call fs.move with the correct values', async () => {
|
||||
mockInputFiles.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file.from);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(true);
|
||||
});
|
||||
|
||||
await action.handler(mockContext);
|
||||
|
||||
mockInputFiles.forEach(file => {
|
||||
const filePath = resolvePath(workspacePath, file.from);
|
||||
const fileExists = fs.existsSync(filePath);
|
||||
expect(fileExists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should override when requested', async () => {
|
||||
const sourceFile = 'unit-test-a.js';
|
||||
const destFile = 'unit-test-c.js';
|
||||
const sourceFilePath = resolvePath(workspacePath, sourceFile);
|
||||
const destFilePath = resolvePath(workspacePath, destFile);
|
||||
|
||||
const sourceBeforeContent = fs.readFileSync(sourceFilePath, 'utf-8');
|
||||
const destBeforeContent = fs.readFileSync(destFilePath, 'utf-8');
|
||||
|
||||
expect(sourceBeforeContent).not.toEqual(destBeforeContent);
|
||||
|
||||
|
||||
await action.handler({
|
||||
...mockContext,
|
||||
input: {
|
||||
files: [
|
||||
{
|
||||
from: sourceFile,
|
||||
to: destFile,
|
||||
overwrite: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const destAfterContent = fs.readFileSync(destFilePath, 'utf-8');
|
||||
|
||||
expect(sourceBeforeContent).toEqual(destAfterContent);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 { createTemplateAction } from '../../createTemplateAction';
|
||||
import { resolveSafeChildPath } from '@backstage/backend-common';
|
||||
|
||||
import { InputError } from '@backstage/errors';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
import fs from 'fs-extra';
|
||||
|
||||
interface FilesToRename extends JsonObject {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const createFilesystemRenameAction = () => {
|
||||
return createTemplateAction<{ files: FilesToRename }>({
|
||||
id: 'fs:rename',
|
||||
schema: {
|
||||
input: {
|
||||
required: ['files'],
|
||||
type: 'object',
|
||||
properties: {
|
||||
files: {
|
||||
title: 'Files',
|
||||
description: 'A list of file names that will be renamed',
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
required: ['from', 'to'],
|
||||
properties: {
|
||||
from: {
|
||||
type: 'string',
|
||||
title: 'The source location of the file to be renamed',
|
||||
},
|
||||
to: {
|
||||
type: 'string',
|
||||
title: 'The destination of the new file',
|
||||
},
|
||||
overwrite: {
|
||||
type: 'boolean',
|
||||
title:
|
||||
'Overwrite existing file or directory, default is false',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async handler(ctx) {
|
||||
if (!Array.isArray(ctx.input?.files)) {
|
||||
throw new InputError('files must be an Array');
|
||||
}
|
||||
|
||||
for (const file of ctx.input.files) {
|
||||
if (!file.from || !file.to) {
|
||||
throw new InputError('each file must have a from and to property');
|
||||
}
|
||||
|
||||
const sourceFilepath = resolveSafeChildPath(ctx.workspacePath, file.from);
|
||||
const destFilepath = resolveSafeChildPath(ctx.workspacePath, file.to);
|
||||
|
||||
try {
|
||||
await fs.move(sourceFilepath, destFilepath, {
|
||||
overwrite: file.overwrite || false,
|
||||
});
|
||||
ctx.logger.info(
|
||||
`File ${sourceFilepath} renamed to ${destFilepath} successfully`,
|
||||
);
|
||||
} catch (err) {
|
||||
ctx.logger.error(
|
||||
`Failed to rename file ${sourceFilepath} to ${destFilepath}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -18,4 +18,5 @@ export * from './catalog';
|
||||
export { createBuiltinActions } from './createBuiltinActions';
|
||||
export * from './debug';
|
||||
export * from './fetch';
|
||||
export * from './filesystem';
|
||||
export * from './publish';
|
||||
|
||||
Reference in New Issue
Block a user