From cae880a3d5c7b2b341a4bacda08d6062676846f6 Mon Sep 17 00:00:00 2001 From: rakhov Date: Fri, 6 Mar 2026 14:01:39 +0100 Subject: [PATCH 1/3] Add --reverse command to create templates from folders Before this commit, boil could only generate projects from templates. After this commit, users can also create templates from existing projects using the --reverse flag. - Add --reverse flag parsing to CLI with new InputArgs union type - Add createTemplateFromFolder function to recursively read folders - Extend TemplateEnv with readdir, stat, and relative path methods - Update main.ts to handle both create and reverse flows - Add tests for --reverse flag parsing --- src/domain/__tests__/cli.spec.ts | 55 +++++++++++++ src/domain/__tests__/mocks.ts | 2 + src/domain/cli.ts | 38 ++++++++- src/domain/template.ts | 131 +++++++++++++++++++++++++++++++ src/main.ts | 45 +++++++---- 5 files changed, 254 insertions(+), 17 deletions(-) diff --git a/src/domain/__tests__/cli.spec.ts b/src/domain/__tests__/cli.spec.ts index 998cfa7..eb62779 100644 --- a/src/domain/__tests__/cli.spec.ts +++ b/src/domain/__tests__/cli.spec.ts @@ -11,6 +11,7 @@ describe('cli', () => { const actual = parseInputArgs(argv)(env); const expected = E.right({ + type: 'create', projectName: 'my-project', templatePath: './template.hsfiles', }); @@ -29,6 +30,7 @@ describe('cli', () => { const actual = parseInputArgs(argv)(env); const expected = E.right({ + type: 'create', projectName: 'my-app', templatePath: 'https://example.com/template.hsfiles', }); @@ -54,6 +56,59 @@ describe('cli', () => { expect(mocks.mockCli.write.mock.calls[0][0]).to.toContain('Usage: boil'); }); + + it('should parse --reverse flag with valid arguments', () => { + const { env } = makeTestEnv(); + const argv = [ + 'node', + 'boil', + '--reverse', + './my-folder', + './output.hsfiles', + ]; + + const actual = parseInputArgs(argv)(env); + const expected = E.right({ + type: 'reverse', + folderPath: './my-folder', + outputPath: './output.hsfiles', + }); + + expect(actual).toStrictEqual(expected); + }); + + it('should parse --reverse flag regardless of position', () => { + const { env } = makeTestEnv(); + const argv = [ + 'node', + 'boil', + './my-folder', + '--reverse', + './output.hsfiles', + ]; + + const actual = parseInputArgs(argv)(env); + const expected = E.right({ + type: 'reverse', + folderPath: './my-folder', + outputPath: './output.hsfiles', + }); + + expect(actual).toStrictEqual(expected); + }); + + it('should return error when --reverse flag has insufficient arguments', () => { + const { env, mocks } = makeTestEnv(); + const argv = ['node', 'boil', '--reverse', './my-folder']; + + const actual = parseInputArgs(argv)(env); + + expect(actual.type).toStrictEqual('left'); + expect(mocks.mockCli.write).toBeCalledTimes(1); + expect(mocks.mockCli.write.mock.calls[0][0]).to.toContain( + 'Usage: boil --reverse', + ); + }); }); describe('promptForVariables', () => { diff --git a/src/domain/__tests__/mocks.ts b/src/domain/__tests__/mocks.ts index 43c253b..b9a06d6 100644 --- a/src/domain/__tests__/mocks.ts +++ b/src/domain/__tests__/mocks.ts @@ -14,6 +14,8 @@ export const makeTestEnv = () => { access: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + readdir: vi.fn(), + stat: vi.fn(), }, mockLogger: { info: vi.fn(), diff --git a/src/domain/cli.ts b/src/domain/cli.ts index a5c04a9..4daa23b 100644 --- a/src/domain/cli.ts +++ b/src/domain/cli.ts @@ -8,17 +8,49 @@ export interface CliEnv { }; } -interface InputArgs { +interface CreateArgs { + readonly type: 'create'; readonly projectName: string; readonly templatePath: string; } +interface ReverseArgs { + readonly type: 'reverse'; + readonly folderPath: string; + readonly outputPath: string; +} + +export type InputArgs = CreateArgs | ReverseArgs; + export const parseInputArgs = (argv: readonly string[]) => (env: CliEnv): E.Either => { // the first argument is node, the second is the file executed const args = argv.slice(2); + // Check for --reverse flag + if (args.includes('--reverse')) { + const argsWithoutFlag = args.filter((a) => a !== '--reverse'); + if (argsWithoutFlag.length < 2) { + const message = [ + 'Usage: boil --reverse ', + '', + 'Examples:', + ' boil --reverse ./my-project ./template.hsfiles', + ' boil --reverse ./src/components ./components-template.hsfiles', + ].join('\n'); + + // eslint-disable-next-line functional/no-expression-statements + env.cli.write(message); + return E.left(message); + } + return E.right({ + type: 'reverse', + folderPath: argsWithoutFlag[0], + outputPath: argsWithoutFlag[1], + }); + } + if (args.length < 2) { const message = [ 'Usage: boil ', @@ -27,6 +59,9 @@ export const parseInputArgs = ' boil my-project ./templates/basic', ' boil my-app https://raw.githubusercontent.com/user/repo/main/template.hsfiles', ' boil my-project ./template.hsfiles --verbose', + '', + 'Reverse mode (create template from folder):', + ' boil --reverse ', ].join('\n'); // eslint-disable-next-line functional/no-expression-statements @@ -34,6 +69,7 @@ export const parseInputArgs = return E.left(message); } else { return E.right({ + type: 'create', projectName: args[0], templatePath: args[1], }); diff --git a/src/domain/template.ts b/src/domain/template.ts index 37c887e..0375862 100644 --- a/src/domain/template.ts +++ b/src/domain/template.ts @@ -21,11 +21,17 @@ export interface TemplateEnv { options: { readonly recursive: true }, ) => Promise; readonly writeFile: (typeof nodeFS)['promises']['writeFile']; + readonly readdir: ( + path: nodeFS.PathLike, + options: { readonly withFileTypes: true }, + ) => Promise; + readonly stat: (path: nodeFS.PathLike) => Promise; }; readonly path: { readonly dirname: (typeof nodePath)['dirname']; readonly resolve: (typeof nodePath)['resolve']; readonly join: (typeof nodePath)['join']; + readonly relative: (typeof nodePath)['relative']; }; } @@ -212,3 +218,128 @@ export const createFiles = logger.info(`Generated ${template.files.length} files`); return E.right(void 0); }; + +interface CollectedFile { + readonly relativePath: string; + readonly content: string; +} + +const collectFiles = + (rootPath: string, currentPath: string) => + async ( + env: TemplateEnv & LoggerEnv, + ): Promise> => { + const { fs, path } = env; + + const entries = await E.tryCatch( + () => fs.readdir(currentPath, { withFileTypes: true }), + (e) => new Error(`Failed to read directory ${currentPath}: ${e}`), + ); + + if (entries.type === 'left') return entries; + + // eslint-disable-next-line functional/prefer-readonly-type + const files: CollectedFile[] = []; + + // eslint-disable-next-line functional/no-loop-statements + for (const entry of entries.value) { + const fullPath = path.join(currentPath, entry.name); + + if (entry.isDirectory()) { + const subFiles = await collectFiles(rootPath, fullPath)(env); + if (subFiles.type === 'left') return subFiles; + // eslint-disable-next-line functional/no-expression-statements, functional/immutable-data + files.push(...subFiles.value); + } else if (entry.isFile()) { + const content = await E.tryCatch( + () => fs.readFile(fullPath, 'utf-8'), + (e) => new Error(`Failed to read file ${fullPath}: ${e}`), + ); + if (content.type === 'left') return content; + + const relativePath = path.relative(rootPath, fullPath); + // eslint-disable-next-line functional/no-expression-statements, functional/immutable-data + files.push({ relativePath, content: content.value }); + } + } + + return E.right(files); + }; + +const generateTemplateContent = (files: readonly CollectedFile[]): string => { + return files + .map((file) => `{-# START_FILE ${file.relativePath} #-}\n${file.content}`) + .join('\n'); +}; + +export const createTemplateFromFolder = + (folderPath: string, outputPath: string) => + async (env: TemplateEnv & LoggerEnv): Promise> => { + const { fs, path, logger } = env; + + const resolvedFolder = path.resolve(folderPath); + const resolvedOutput = path.resolve(outputPath); + + logger.info(`Creating template from folder: ${resolvedFolder}`); + + // Check if folder exists + const folderExists = await E.tryCatch( + () => fs.stat(resolvedFolder), + () => new Error(`Folder does not exist: ${resolvedFolder}`), + ); + + if (folderExists.type === 'left') return folderExists; + if (!folderExists.value.isDirectory()) + return E.left(new Error(`Path is not a directory: ${resolvedFolder}`)); + + // Check if output already exists + const outputExists = await E.tryCatch( + () => fs.access(resolvedOutput, fs.constants.F_OK), + () => new Error(), + ); + + if (outputExists.type === 'right') + return E.left(new Error(`Output file already exists: ${resolvedOutput}`)); + + // Collect all files + const collectedFiles = await collectFiles( + resolvedFolder, + resolvedFolder, + )(env); + + if (collectedFiles.type === 'left') return collectedFiles; + + if (collectedFiles.value.length === 0) + return E.left(new Error('No files found in folder')); + + logger.info(`Found ${collectedFiles.value.length} files`); + + // Generate template content + const templateContent = generateTemplateContent(collectedFiles.value); + + // Ensure output directory exists + const outputDir = path.dirname(resolvedOutput); + const outputDirExists = await E.tryCatch( + () => fs.access(outputDir, fs.constants.F_OK), + () => new Error(), + ); + if (outputDirExists.type === 'left') { + const createDir = await E.tryCatch( + () => fs.mkdir(outputDir, { recursive: true }), + (e) => new Error(`Failed to create output directory: ${e}`), + ); + if (createDir.type === 'left') return createDir; + } + + // Write template file + const writeResult = await E.tryCatch( + () => fs.writeFile(resolvedOutput, templateContent), + (e) => new Error(`Failed to write template file: ${e}`), + ); + + if (writeResult.type === 'left') return writeResult; + + logger.info(`Template created successfully: ${resolvedOutput}`); + logger.info(`Generated template with ${collectedFiles.value.length} files`); + return E.right(void 0); + }; diff --git a/src/main.ts b/src/main.ts index b851c47..580cba9 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,6 +2,7 @@ import nodeFS from 'node:fs'; import path from 'node:path'; import { createFiles, + createTemplateFromFolder, fetchTemplate, parseTemplate, } from './domain/template.js'; @@ -21,14 +22,11 @@ const logErrorAndExit = (value: T) => { process.exit(1); }; -// Main function -const main = async () => { - const args = parseInputArgs(process.argv)(env); - if (args.type === 'left') return logErrorAndExit(args.value); +// Handle create mode: template -> project +const handleCreate = async (projectName: string, templatePath: string) => { + env.cli.write(`Creating project: ${projectName}\n`); - env.cli.write(`Creating project: ${args.value.projectName}\n`); - - const templateContent = await fetchTemplate(args.value.templatePath)(env); + const templateContent = await fetchTemplate(templatePath)(env); if (templateContent.type === 'left') return logErrorAndExit(templateContent.value); @@ -36,20 +34,35 @@ const main = async () => { if (template.files.length === 0) return logErrorAndExit('No files found in template'); - const values = await promptForVariables( - template.variables, - args.value.projectName, - )(env); + const values = await promptForVariables(template.variables, projectName)(env); if (values.type === 'left') return logErrorAndExit(values.value); - const create = await createFiles( - args.value.projectName, - template, - values.value, - )(env); + const create = await createFiles(projectName, template, values.value)(env); if (create.type === 'left') return logErrorAndExit(create.value); }; +// Handle reverse mode: folder -> template +const handleReverse = async (folderPath: string, outputPath: string) => { + env.cli.write(`Creating template from folder: ${folderPath}\n`); + + const result = await createTemplateFromFolder(folderPath, outputPath)(env); + if (result.type === 'left') return logErrorAndExit(result.value); + + env.cli.write(`Template created: ${outputPath}\n`); +}; + +// Main function +const main = async () => { + const args = parseInputArgs(process.argv)(env); + if (args.type === 'left') return logErrorAndExit(args.value); + + if (args.value.type === 'reverse') { + return handleReverse(args.value.folderPath, args.value.outputPath); + } else { + return handleCreate(args.value.projectName, args.value.templatePath); + } +}; + // Run if called directly if (require.main === module) { main(); From 5c7336ed76c871f48b12884d55bd8cdf1aa780f5 Mon Sep 17 00:00:00 2001 From: rakhov Date: Fri, 6 Mar 2026 14:07:12 +0100 Subject: [PATCH 2/3] Add --reverse feature documentation to README Before this commit, the README only documented the standard scaffolding flow. After this commit, users can find documentation for the reverse mode that creates templates from existing folders. --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 8a76688..b933c4d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A simple project scaffolding tool that generates project structures from hsfiles ## Features - Generate project scaffolds from templates +- Create templates from existing folders (reverse mode) - Support for local files and remote URLs - Variable substitution with `{{variable}}` syntax - Interactive prompts for missing variables @@ -47,6 +48,24 @@ For convenience, you can also use the remote runner script: curl -fsSL https://raw.githubusercontent.com/datalek/boil/main/scripts/remote.sh | bash -s -- ``` +### Reverse Mode + +Create a template from an existing project folder: + +```bash +boil --reverse +``` + +This is useful for converting existing projects into reusable templates. + +```bash +# Create template from a project folder +boil --reverse ./my-project ./templates/my-template.hsfiles + +# Create template from a specific directory +boil --reverse ./src/components ./components-template.hsfiles +``` + ## Template Format Boil uses a simplified hsfiles format: From b17a647ad7b6ec333f3f5933bca1691910dc5792 Mon Sep 17 00:00:00 2001 From: rakhov Date: Fri, 6 Mar 2026 14:50:04 +0100 Subject: [PATCH 3/3] Add changeset for --reverse command --- .changeset/add-reverse-command.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/add-reverse-command.md diff --git a/.changeset/add-reverse-command.md b/.changeset/add-reverse-command.md new file mode 100644 index 0000000..a0d1eae --- /dev/null +++ b/.changeset/add-reverse-command.md @@ -0,0 +1,7 @@ +--- +"boil": minor +--- + +Add --reverse command to create templates from folders + +This adds a new reverse mode that generates hsfiles templates from existing project folders. Use `boil --reverse ` to convert any project into a reusable template.