diff --git a/.changeset/violet-parrots-knock.md b/.changeset/violet-parrots-knock.md new file mode 100644 index 0000000000..dde0f9e606 --- /dev/null +++ b/.changeset/violet-parrots-knock.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Add new `fs:readdir` action to list current content of the workspace diff --git a/plugins/scaffolder-backend/report.api.md b/plugins/scaffolder-backend/report.api.md index 8fc5248e6f..cb5da51ea4 100644 --- a/plugins/scaffolder-backend/report.api.md +++ b/plugins/scaffolder-backend/report.api.md @@ -231,6 +231,26 @@ export const createFilesystemDeleteAction: () => TemplateAction_2< JsonObject >; +// @public +export const createFilesystemReadDirAction: () => TemplateAction_2< + { + paths: string[]; + recursive?: boolean | undefined; + }, + { + files: { + name: string; + path: string; + fullPath: string; + }[]; + folders: { + name: string; + path: string; + fullPath: string; + }[]; + } +>; + // @public export const createFilesystemRenameAction: () => TemplateAction_2< { diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 59182ab46d..d2a03412cc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -41,6 +41,7 @@ import { } from './fetch'; import { createFilesystemDeleteAction, + createFilesystemReadDirAction, createFilesystemRenameAction, } from './filesystem'; import { @@ -224,6 +225,7 @@ export const createBuiltinActions = ( createFetchCatalogEntityAction({ catalogClient, auth }), createCatalogWriteAction(), createFilesystemDeleteAction(), + createFilesystemReadDirAction(), createFilesystemRenameAction(), createGithubActionsDispatchAction({ integrations, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts index 84e477b16b..159d438f27 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/index.ts @@ -16,3 +16,4 @@ export { createFilesystemDeleteAction } from './delete'; export { createFilesystemRenameAction } from './rename'; +export { createFilesystemReadDirAction } from './read'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.test.ts new file mode 100644 index 0000000000..5654fc4c07 --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { createFilesystemReadDirAction } from './read'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { resolve as resolvePath } from 'path'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { examples } from './read.examples'; +import yaml from 'yaml'; + +describe('fs:readdir examples', () => { + const action = createFilesystemReadDirAction(); + + const mockDir = createMockDirectory(); + mockDir.setContent({ + workspace: { + 'file1.txt': 'hello', + 'file2.txt': 'world', + docs: { + 'doc1.md': 'hello', + 'doc2.md': 'world', + assets: { + 'asset1.jpg': 'sddfsdfsdfsdf', + }, + }, + }, + }); + const workspacePath = resolvePath(mockDir.path, 'workspace'); + + beforeEach(() => { + jest.restoreAllMocks(); + }); + + it('should be able to read workspace root', async () => { + const example = examples[0].example; + const mockContext = createMockActionContext({ + workspacePath, + input: yaml.parse(example).steps[0].input, + }); + await action.handler(mockContext); + expect(mockContext.output).toHaveBeenNthCalledWith(1, 'files', [ + { + name: 'file1.txt', + path: 'file1.txt', + fullPath: resolvePath(workspacePath, 'file1.txt'), + }, + { + name: 'file2.txt', + path: 'file2.txt', + fullPath: resolvePath(workspacePath, 'file2.txt'), + }, + ]); + expect(mockContext.output).toHaveBeenNthCalledWith(2, 'folders', [ + { + name: 'docs', + path: 'docs', + fullPath: resolvePath(workspacePath, 'docs'), + }, + ]); + }); + + it('should be able to read directory recursively', async () => { + const example = examples[1].example; + const mockContext = createMockActionContext({ + workspacePath, + input: yaml.parse(example).steps[0].input, + }); + await action.handler(mockContext); + expect(mockContext.output).toHaveBeenNthCalledWith(1, 'files', [ + { + name: 'doc1.md', + path: 'docs/doc1.md', + fullPath: resolvePath(workspacePath, 'docs/doc1.md'), + }, + { + name: 'doc2.md', + path: 'docs/doc2.md', + fullPath: resolvePath(workspacePath, 'docs/doc2.md'), + }, + { + name: 'asset1.jpg', + path: 'docs/assets/asset1.jpg', + fullPath: resolvePath(workspacePath, 'docs/assets/asset1.jpg'), + }, + ]); + expect(mockContext.output).toHaveBeenNthCalledWith(2, 'folders', [ + { + name: 'assets', + path: 'docs/assets', + fullPath: resolvePath(workspacePath, 'docs/assets'), + }, + ]); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.ts new file mode 100644 index 0000000000..11e34c242e --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.examples.ts @@ -0,0 +1,67 @@ +/* + * 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import * as yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Read complete content of workspace', + example: yaml.stringify({ + steps: [ + { + action: 'fs:readdir', + id: 'read-workdir', + name: 'Read workspace directory', + input: { + paths: ['.'], + }, + }, + ], + }), + }, + { + description: 'Get content of the docs folder', + example: yaml.stringify({ + steps: [ + { + action: 'fs:readdir', + id: 'read-workdir', + name: 'Read workspace directory', + input: { + paths: ['docs'], + recursive: true, + }, + }, + ], + }), + }, + { + description: 'Get content of multiple folders', + example: yaml.stringify({ + steps: [ + { + action: 'fs:readdir', + id: 'read-workdir', + name: 'Read workspace directory', + input: { + paths: ['foo', 'bar'], + recursive: true, + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts new file mode 100644 index 0000000000..0351e7cb0e --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/filesystem/read.ts @@ -0,0 +1,82 @@ +/* + * 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 { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import z from 'zod'; +import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; +import fs from 'fs/promises'; +import path from 'path'; + +const contentSchema = z.object({ + name: z.string().describe('Name of the file or directory'), + path: z + .string() + .describe('path to the file or directory relative to the workspace'), + fullPath: z.string().describe('full path to the file or directory'), +}); +type Content = z.infer; + +/** + * Creates new action that enables reading directories in the workspace. + * @public + */ +export const createFilesystemReadDirAction = () => { + return createTemplateAction({ + id: 'fs:readdir', + description: 'Reads files and directories from the workspace', + supportsDryRun: true, + schema: { + input: z.object({ + paths: z.array(z.string().min(1)), + recursive: z.boolean().default(false), + }), + output: z.object({ + files: z.array(contentSchema), + folders: z.array(contentSchema), + }), + }, + async handler(ctx) { + const files: Content[] = []; + const folders: Content[] = []; + + for (const localPath of ctx.input.paths) { + const fullWorkspacePath = resolveSafeChildPath( + ctx.workspacePath, + localPath, + ); + const content = await fs.readdir(fullWorkspacePath, { + recursive: ctx.input.recursive, + withFileTypes: true, + }); + for (const dirent of content) { + const fullPath = path.join(dirent.parentPath, dirent.name); + const element = { + name: dirent.name, + path: path.relative(ctx.workspacePath, fullPath), + fullPath, + }; + if (dirent.isDirectory()) { + folders.push(element); + } else { + files.push(element); + } + } + } + + ctx.output('files', files); + ctx.output('folders', folders); + }, + }); +};