Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/add-reverse-command.md
Original file line number Diff line number Diff line change
@@ -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 <folder-path> <output-template-path>` to convert any project into a reusable template.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 -- <project-name> <template-path>
```

### Reverse Mode

Create a template from an existing project folder:

```bash
boil --reverse <folder-path> <output-template-path>
```

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:
Expand Down
55 changes: 55 additions & 0 deletions src/domain/__tests__/cli.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ describe('cli', () => {

const actual = parseInputArgs(argv)(env);
const expected = E.right({
type: 'create',
projectName: 'my-project',
templatePath: './template.hsfiles',
});
Expand All @@ -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',
});
Expand All @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/domain/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export const makeTestEnv = () => {
access: vi.fn<TemplateEnv['fs']['access']>(),
mkdir: vi.fn<TemplateEnv['fs']['mkdir']>(),
writeFile: vi.fn<TemplateEnv['fs']['writeFile']>(),
readdir: vi.fn<TemplateEnv['fs']['readdir']>(),
stat: vi.fn<TemplateEnv['fs']['stat']>(),
},
mockLogger: {
info: vi.fn<LoggerEnv['logger']['info']>(),
Expand Down
38 changes: 37 additions & 1 deletion src/domain/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, InputArgs> => {
// 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 <folder-path> <output-template-path>',
'',
'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 <project-name> <template-path>',
Expand All @@ -27,13 +59,17 @@ 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 <folder-path> <output-template-path>',
].join('\n');

// eslint-disable-next-line functional/no-expression-statements
env.cli.write(message);
return E.left(message);
} else {
return E.right({
type: 'create',
projectName: args[0],
templatePath: args[1],
});
Expand Down
131 changes: 131 additions & 0 deletions src/domain/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ export interface TemplateEnv {
options: { readonly recursive: true },
) => Promise<string | undefined>;
readonly writeFile: (typeof nodeFS)['promises']['writeFile'];
readonly readdir: (
path: nodeFS.PathLike,
options: { readonly withFileTypes: true },
) => Promise<readonly nodeFS.Dirent[]>;
readonly stat: (path: nodeFS.PathLike) => Promise<nodeFS.Stats>;
};
readonly path: {
readonly dirname: (typeof nodePath)['dirname'];
readonly resolve: (typeof nodePath)['resolve'];
readonly join: (typeof nodePath)['join'];
readonly relative: (typeof nodePath)['relative'];
};
}

Expand Down Expand Up @@ -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<E.Either<Error, readonly CollectedFile[]>> => {
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<E.Either<Error, void>> => {
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);
};
Loading