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. 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: 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();