Compare commits

...

26 Commits

Author SHA1 Message Date
02dcbdaf13 chore(master): release 3.1.0 2026-03-23 17:21:02 +02:00
970e8e0b37 build: fix build warnings & reduce bundle size 2026-03-23 17:17:23 +02:00
c860e4644a fix: interactive inputs with existing config/cli options 2026-03-23 17:14:25 +02:00
bb0248f91a feat: update all ouput logging 2026-03-23 17:02:33 +02:00
972d199fbb feat: config validation 2026-03-23 16:53:55 +02:00
b5fd1df821 feat: scaffoldignore 2026-03-23 16:41:08 +02:00
f6408f221d feat: select, confirm and number input types 2026-03-23 16:32:41 +02:00
0a4ead17c0 feat: after scaffold hook 2026-03-23 16:28:40 +02:00
7926b15053 feat: init command 2026-03-23 16:22:43 +02:00
9cdea1c5ea build: add lint-staged 2026-03-23 16:18:21 +02:00
4a79961ae5 build: update node version 2026-03-23 14:39:31 +02:00
51f422cc90 chore(master): release 3.0.0 2026-03-23 14:25:33 +02:00
93f3a4caaf chore(deps): update docs dependencies 2026-03-23 12:37:27 +02:00
04e7e895d7 feat: add .scaffold.{ext} as auto-detected file 2026-03-23 12:34:48 +02:00
68e6d17fa9 feat: auto-detect config file
Release-As: 3.0.0
2026-03-23 12:29:57 +02:00
d64dd4f0e7 feat: predefined data inputs 2026-03-23 12:27:55 +02:00
519ef273ac feat: interactive inputs 2026-03-23 12:16:24 +02:00
1431fda3db docs: fixes 2026-03-23 12:16:24 +02:00
2229a9cda1 refactor: reorganize and simplify all logic 2026-03-23 11:57:05 +02:00
1f80a50185 docs: update README.md 2026-03-23 11:49:18 +02:00
e82827d909 build: update workflows 2026-03-23 11:45:52 +02:00
2e49448e59 docs: update docs build 2026-03-23 11:39:47 +02:00
0ffd7ef788 build: migrate to vite+vitest 2026-03-23 10:45:57 +02:00
d16fb17c38 test: add comprehensive tests 2026-03-23 10:38:00 +02:00
af33c059b9 fix: string helpers to words parts conversion 2026-03-23 10:37:54 +02:00
d487d36b04 chore(deps): update dependencies 2026-03-23 10:24:27 +02:00
50 changed files with 12256 additions and 8221 deletions

View File

@@ -1,30 +0,0 @@
name: Documentation
on:
push:
branches:
- master
permissions:
contents: write
jobs:
docs:
name: Build Documentation
runs-on: ubuntu-latest
# if: "contains(github.event.head_commit.message, 'chore(release)')"
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: |
pnpm install --frozen-lockfile
cd docs && pnpm install --frozen-lockfile
- run: pnpm docs:build
- name: Deploy on GitHub Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build

View File

@@ -11,6 +11,7 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
test:
@@ -18,11 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: pnpm run ci
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
build:
@@ -30,11 +32,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm i -g pnpm
- run: pnpm run ci
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm build
release:
@@ -59,15 +62,39 @@ jobs:
needs: release
runs-on: ubuntu-latest
if: ${{ needs.release.outputs.release_created }}
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
registry-url: "https://registry.npmjs.org"
- run: npm --version
- run: pnpm install --frozen-lockfile
- run: pnpm build
- run: cd dist && npm publish --provenance --access public
docs:
name: Deploy Documentation
needs: release
runs-on: ubuntu-latest
if: ${{ needs.release.outputs.release_created }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: "https://registry.npmjs.org"
- run: npm i -g pnpm
- run: pnpm run ci
- run: pnpm build
- run: cd dist && npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: cd docs && pnpm install --frozen-lockfile
- run: pnpm docs:build
- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/build

1
.husky/pre-commit Normal file
View File

@@ -0,0 +1 @@
pnpm lint-staged

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
docs/docs/api/
examples/
.github/
CHANGELOG.md
pnpm-lock.yaml

View File

@@ -1,8 +1,9 @@
{
"semi": false,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"printWidth": 100,
"overrides": [
{
"files": "*.md",

View File

@@ -1,5 +1,37 @@
# Change Log
## [3.1.0](https://github.com/chenasraf/simple-scaffold/compare/v3.0.0...v3.1.0) (2026-03-23)
### Features
* after scaffold hook ([0a4ead1](https://github.com/chenasraf/simple-scaffold/commit/0a4ead17c013b5410e8eec8000e83fa7b7186fbb))
* config validation ([972d199](https://github.com/chenasraf/simple-scaffold/commit/972d199fbb7167506f76eba697fbea6acea9de56))
* init command ([7926b15](https://github.com/chenasraf/simple-scaffold/commit/7926b15053726d5eb9e5772b5f04c3989e567b2c))
* scaffoldignore ([b5fd1df](https://github.com/chenasraf/simple-scaffold/commit/b5fd1df821eba7e13e5ceedc53da550c8dd7cd8e))
* select, confirm and number input types ([f6408f2](https://github.com/chenasraf/simple-scaffold/commit/f6408f221d84d84c5671737af63ff8079093fd27))
* update all ouput logging ([bb0248f](https://github.com/chenasraf/simple-scaffold/commit/bb0248f91a012c297e408f15f0846c5272166543))
### Bug Fixes
* interactive inputs with existing config/cli options ([c860e46](https://github.com/chenasraf/simple-scaffold/commit/c860e4644a3f3d4bd6b7dab9974accdf8bae9463))
## [3.0.0](https://github.com/chenasraf/simple-scaffold/compare/v2.3.3...v3.0.0) (2026-03-23)
### Features
* add .scaffold.{ext} as auto-detected file ([04e7e89](https://github.com/chenasraf/simple-scaffold/commit/04e7e895d7d97e29cf1ded2f2d0ac8e6a237b997))
* auto-detect config file ([68e6d17](https://github.com/chenasraf/simple-scaffold/commit/68e6d17fa9898dffbc620eba0140748a90bc007f))
* interactive inputs ([519ef27](https://github.com/chenasraf/simple-scaffold/commit/519ef273ac3db4b7a1e71c8e1c456aa1334d6fbd))
* predefined data inputs ([d64dd4f](https://github.com/chenasraf/simple-scaffold/commit/d64dd4f0e775d3bff3efb074d0af23d54edcbaab))
### Bug Fixes
* string helpers to words parts conversion ([af33c05](https://github.com/chenasraf/simple-scaffold/commit/af33c059b91d3f463a5d174ab3a0119c577880c5))
## [2.3.3](https://github.com/chenasraf/simple-scaffold/compare/v2.3.2...v2.3.3) (2025-06-18)

View File

@@ -13,17 +13,12 @@
</h2>
Looking to streamline your workflow and get your projects up and running quickly? Look no further
than Simple Scaffold - the easy-to-use NPM package that simplifies the process of organizing and
copying your commonly-created files.
Simple Scaffold is a file scaffolding tool. You define templates once, then generate files from them
whenever you need — whether it's a single component or an entire app boilerplate.
With its agnostic and un-opinionated approach, Simple Scaffold can handle anything from a few simple
files to an entire app boilerplate setup. Plus, with the power of **Handlebars.js** syntax, you can
easily replace custom data and personalize your files to fit your exact needs. But that's not all -
you can also use it to loop through data, use conditions, and write custom functions using helpers.
Don't waste any more time manually copying and pasting files - let Simple Scaffold do the heavy
lifting for you and start building your projects faster and more efficiently today!
Templates use **Handlebars.js** syntax, so you can inject data, loop over lists, use conditionals,
and write custom helpers. It works as a CLI or as a Node.js library, and it doesn't care what kind
of files you're generating.
<div align="center">
@@ -45,6 +40,21 @@ See full documentation [here](https://chenasraf.github.io/simple-scaffold).
## Getting Started
### Quick Start
The fastest way to get started is to run `init` in your project directory:
```sh
npx simple-scaffold init
```
This creates a `scaffold.config.js` and an example template in `templates/default/`. Then generate
files with:
```sh
npx simple-scaffold MyProject
```
### Cheat Sheet
A quick rundown of common usage scenarios:
@@ -100,9 +110,40 @@ See information about each option and flag using the `--help` flag, or read the
[CLI documentation](https://chenasraf.github.io/simple-scaffold/docs/usage/cli). For information
about how configuration files work, [see below](#configuration-files).
### Interactive Mode
When running in a terminal, Simple Scaffold will interactively prompt for any missing required
values — name, output directory, template paths, and template key (if multiple are available).
Config files can also define **inputs** — custom fields that are prompted interactively and become
template data:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
},
}
```
Inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```sh
npx simple-scaffold -c scaffold.config.js -k component -D author=John MyComponent
```
### Configuration Files
You can use a config file to more easily maintain all your scaffold definitions.
You can use a config file to more easily maintain all your scaffold definitions. Simple Scaffold
**auto-detects** config files in the current directory — no `--config` flag needed.
It searches for these files in order: `scaffold.config.{mjs,cjs,js,json}`,
`scaffold.{mjs,cjs,js,json}`, `.scaffold.{mjs,cjs,js,json}`.
`scaffold.config.js`
@@ -120,10 +161,11 @@ module.exports = {
}
```
Then call your scaffold like this:
Then just run from the same directory:
```sh
$ npx simple-scaffold -c scaffold.config.js PageWrapper
$ npx simple-scaffold PageWrapper
# or explicitly: npx simple-scaffold -c scaffold.config.js PageWrapper
```
This will allow you to avoid needing to remember which configs are needed or to store them in a

View File

@@ -22,6 +22,25 @@ Examples:
| `./templates/directory` | `outer/{{name}}.txt`,<br />`outer2/inner/{{name}}.txt` | `src/outer/AppName.txt`,<br />`src/outer2/inner/AppName.txt` |
| `./templates/others/**/*.txt` | `outer/{{name}}.jpg`,<br />`outer2/inner/{{name}}.txt` | `src/outer2/inner/AppName.txt` |
## Ignoring files
You can create a `.scaffoldignore` file in your template directory to exclude files from being
copied to the output. It works like `.gitignore` — one pattern per line, comments with `#`.
```text
# .scaffoldignore
*.log
node_modules
README.md
dist/**
```
The `.scaffoldignore` file itself is never copied to the output.
Patterns are matched against both the file's basename and its path relative to the template
directory, so `README.md` will match at any depth, while `dist/**` only matches a `dist` directory
at the template root.
## Variable/token replacement
Scaffolding will replace `{{ varName }}` in both the file name and its contents and put the

View File

@@ -23,7 +23,8 @@ module.exports = {
}
```
For the full configuration options, see [ScaffoldConfigFile](../api/modules#scaffoldconfigfile).
For the full configuration options, see
[ScaffoldConfigFile](../api/type-aliases/ScaffoldConfigFile).
If you want to supply functions inside the configurations, you must use a `.js`/`.cjs`/`.mjs` file
as JSON does not support non-primitives.
@@ -45,6 +46,40 @@ module.exports = (config) => {
}
```
### Template Inputs
You can define **inputs** in your config to prompt users for custom values when scaffolding. Each
input becomes a template data variable:
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private?", default: false },
port: { type: "number", message: "Port", default: 3000 },
},
},
}
```
In your templates, use these as `{{ author }}`, `{{ license }}`, `{{ private }}`, `{{ port }}`.
Supported input types: `text` (default), `select`, `confirm`, `number`. See
[Template Inputs](cli#template-inputs) for the full reference.
- **Required** inputs are prompted interactively if not provided via `--data` or `-D`
- **Select and confirm** inputs are always prompted unless pre-provided
- **Optional** inputs with a `default` use that value silently if not provided
- In non-interactive environments, only defaults are applied
If you want to provide templates that need no name (such as common config files which are easily
portable between projects), you may provide the `name` property in the config object.
@@ -53,9 +88,38 @@ default and therefore it will no longer be required in the CLI arguments.
## Using a config file
Once your config is created, you can use it by providing the file name to the `--config` (or `-c`
for brevity), optionally alongside `--key` or `-k`, denoting the key to use as the config object, as
you define in your config:
### Auto-detection
By default, Simple Scaffold automatically searches the current working directory for a config file.
No `--config` flag is needed if your config file uses one of the standard names.
The following files are tried in order:
1. `scaffold.config.mjs`
2. `scaffold.config.cjs`
3. `scaffold.config.js`
4. `scaffold.config.json`
5. `scaffold.mjs`
6. `scaffold.cjs`
7. `scaffold.js`
8. `scaffold.json`
9. `.scaffold.mjs`
10. `.scaffold.cjs`
11. `.scaffold.js`
12. `.scaffold.json`
If a config file is found, it is loaded automatically. If multiple templates are defined and no
`--key` is provided, you'll be prompted to select one interactively.
```sh
# Just run from a directory containing scaffold.config.js — no flags needed
simple-scaffold MyComponentName
```
### Explicit config path
You can also provide a specific file or directory path using `--config` (or `-c`), optionally
alongside `--key` or `-k`:
```sh
simple-scaffold -c <file> -k <template_key>
@@ -67,7 +131,12 @@ For example:
simple-scaffold -c scaffold.json -k component MyComponentName
```
If you don't want to supply a template/config name (e.g. `component`), `default` will be used:
When a directory is given, the same auto-detection order listed above is used to find a config file
within that directory.
### Default template key
If you don't supply a template key (e.g. `component`), `default` will be used:
```js
/** @type {import('simple-scaffold').ScaffoldConfigFile} */
@@ -78,36 +147,23 @@ module.exports = {
}
```
And then:
```sh
# will use 'default' template
simple-scaffold -c scaffold.json MyComponentName
```
- When the a directory is given, the following files in the given directory will be tried in order:
- `scaffold.config.*`
- `scaffold.*`
Where `*` denotes any supported file extension, in the priority listed in
[Supported file types](#supported-file-types)
- When the `template_key` is ommitted, `default` will be used as default.
If multiple keys exist and no key is specified, you'll be prompted to choose one interactively.
### Supported file types
Any importable file is supported, depending on your build process.
Common files include:
Common extensions:
- `*.mjs`
- `*.cjs`
- `*.js`
- `*.json`
When filenames are ommited when loading configs, these are the file extensions that will be
automatically tried, by the specified order of priority.
- `.mjs`
- `.cjs`
- `.js`
- `.json`
Note that you might need to find the correct extension of `.js`, `.cjs` or `.mjs` depending on your
build process and your package type (for example, packages with `"type": "module"` in their

View File

@@ -13,24 +13,85 @@ To see this and more information anytime, add the `-h` or `--help` flag to your
Options:
| Option/flag \| Alias | Description |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. You may omit the `--name` or `-n` for this specific option. |
| `--config` \| `-c` | Filename or directory to load config from |
| `--git` \| `-g` | Git URL or GitHub path to load a template from. |
| `--key` \| `-k` | Key to load inside the config file. This overwrites the config key provided after the colon in `--config` (e.g. `--config scaffold.cmd.js:component)` |
| `--output` \| `-o` | Path to output to. If `--subdir` is enabled, the subdir will be created inside this path. Default is current working directory. |
| `--templates` \| `-t` | Template files to use as input. You may provide multiple files, each of which can be a relative or absolute path, or a glob pattern for multiple file matching easily. |
| `--overwrite` \| `-w` \| `--no-overwrite` \| `-W` | Enable to override output files, even if they already exist. (default: false) |
| `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. |
| `--append-data` \| `-D` | Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, which is easier to use with CLI: `-D key1=string -D key2:=raw` |
| `--subdir` \| `-s` \| `--no-subdir` \| `-S` | Create a parent directory with the input name (and possibly `--subdir-helper` (default: false) |
| `--subdir-helper` \| `-H` | Default helper to apply to subdir name when using `--subdir`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) |
| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none, debug, info, warn, error`. The provided level will display messages of the same level or higher. (default: info) |
| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. |
| `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. (default: false) |
| `--version` \| `-v` | Display version. |
| Option/flag \| Alias | Description |
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--name` \| `-n` | Name to be passed to the generated files. `{{name}}` and other data parameters inside contents and file names will be replaced accordingly. If omitted in an interactive terminal, you will be prompted. |
| `--config` \| `-c` | Filename or directory to load config from. If omitted, the current directory is searched automatically for a config file (see [Auto-detection](configuration_files#auto-detection)). |
| `--git` \| `-g` | Git URL or GitHub path to load a template from. |
| `--key` \| `-k` | Key to load inside the config file. If omitted and multiple templates are available, you will be prompted to select one. |
| `--output` \| `-o` | Path to output to. If `--subdir` is enabled, the subdir will be created inside this path. If omitted in an interactive terminal, you will be prompted. |
| `--templates` \| `-t` | Template files to use as input. You may provide multiple files, each of which can be a relative or absolute path, or a glob pattern for multiple file matching easily. If omitted in an interactive terminal, you will be prompted for a comma-separated list. |
| `--overwrite` \| `-w` \| `--no-overwrite` \| `-W` | Enable to override output files, even if they already exist. (default: false) |
| `--data` \| `-d` | Add custom data to the templates. By default, only your app name is included. |
| `--append-data` \| `-D` | Append additional custom data to the templates, which will overwrite `--data`, using an alternate syntax, which is easier to use with CLI: `-D key1=string -D key2:=raw` |
| `--subdir` \| `-s` \| `--no-subdir` \| `-S` | Create a parent directory with the input name (and possibly `--subdir-helper` (default: false) |
| `--subdir-helper` \| `-H` | Default helper to apply to subdir name when using `--subdir`. |
| `--quiet` \| `-q` | Suppress output logs (Same as `--log-level none`)(default: false) |
| `--log-level` \| `-l` | Determine amount of logs to display. The values are: `none, debug, info, warn, error`. The provided level will display messages of the same level or higher. (default: info) |
| `--before-write` \| `-B` | Run a script before writing the files. This can be a command or a path to a file. A temporary file path will be passed to the given command and the command should return a string for the final output. |
| `--after-scaffold` \| `-A` | Run a shell command after all files have been written. The command is executed in the output directory (e.g. `--after-scaffold 'npm install'`). |
| `--dry-run` \| `-dr` | Don't emit files. This is good for testing your scaffolds and making sure they don't fail, without having to write actual file contents or create directories. (default: false) |
| `--version` \| `-v` | Display version. |
### Interactive Mode
When running in a terminal (TTY), Simple Scaffold will prompt for any missing required values:
- **Name** — text input if `--name` is not provided
- **Template key** — selectable list if `--key` is not provided and the config file has multiple
templates
- **Output directory** — text input if `--output` is not provided
- **Template paths** — comma-separated text input if `--templates` is not provided
In non-interactive environments (CI, piped input), missing values will cause an error instead of
prompting.
### Template Inputs
Config files can define **inputs** — custom fields that are prompted interactively and injected as
template data. This is useful for templates that need user-specific values like author name,
license, or description.
```js
module.exports = {
component: {
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private package?", default: false },
port: { type: "number", message: "Dev server port", default: 3000 },
description: { message: "Description" },
},
},
}
```
Each input becomes available as a Handlebars variable in your templates (e.g., `{{ author }}`,
`{{ license }}`).
**Input types:**
| Type | Description | Value type |
| --------- | ------------------------------ | ---------- |
| `text` | Free-form text input (default) | `string` |
| `select` | Choose from a list of options | `string` |
| `confirm` | Yes/no prompt | `boolean` |
| `number` | Numeric input | `number` |
- **Required inputs** without a value will be prompted interactively
- **Select and confirm** inputs are always prompted (unless pre-provided)
- **Optional text/number inputs** with a `default` will use that value silently
- All inputs can be pre-provided via `--data` or `-D` to skip the prompt:
```shell
simple-scaffold -c scaffold.config.js -k component -D author=John -D license=Apache-2.0 MyComponent
```
### Before Write option
@@ -64,12 +125,64 @@ See
Node.js API for more details. Instead of returning `undefined` to keep the default behavior, you can
output `''` for the same effect.
### After Scaffold option
This option runs a shell command after all files have been written. The command is executed in the
output directory, making it useful for post-scaffolding tasks like installing dependencies or
initializing a git repo.
```shell
simple-scaffold -c . --after-scaffold 'npm install'
simple-scaffold -c . --after-scaffold 'git init && git add .'
```
In a config file, you can use a function for more control:
```js
module.exports = {
default: {
templates: ["templates/app"],
output: ".",
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files in ${config.output}`)
// run any post-processing here
},
},
}
```
The function receives a context with the resolved `config` and an array of `files` (absolute paths)
that were written.
## Available Commands:
| Command \| Alias | Description |
| ---------------- | ------------------------------------------------------------------------------------ |
| `init` | Initialize a new scaffold config file and example template in the current directory. |
| `list` \| `ls` | List all available templates for a given config. See `list -h` for more information. |
### `init`
Creates a scaffold config file and an example template directory to get you started quickly.
```shell
simple-scaffold init
```
Options:
| Option | Description |
| ------------------ | -------------------------------------------------------------------- |
| `--dir` \| `-d` | Directory to create the config in. Defaults to current directory. |
| `--format` \| `-f` | Config format: `js`, `mjs`, or `json`. If omitted, you are prompted. |
The command creates:
- A config file (`scaffold.config.js` by default) with a `default` template key
- A `templates/default/` directory with an example `{{name}}.md` template
Existing files are never overwritten.
## Examples:
> See

View File

@@ -7,12 +7,9 @@ title: Node.js Usage
You can build the scaffold yourself, if you want to create more complex arguments, scaffold groups,
etc - simply pass a config object to the Scaffold function when you are ready to start.
The config takes similar arguments to the command line. The full type definitions can be found in
[src/types.ts](https://github.com/chenasraf/simple-scaffold/blob/develop/src/types.ts#L13).
See the full
[documentation](https://chenasraf.github.io/simple-scaffold/interfaces/ScaffoldConfig.html) for the
configuration options and their behavior.
The config takes similar arguments to the command line. See the full
[API documentation](https://chenasraf.github.io/simple-scaffold/docs/api/interfaces/ScaffoldConfig)
for all configuration options and their behavior.
```ts
interface ScaffoldConfig {
@@ -20,19 +17,35 @@ interface ScaffoldConfig {
templates: string[]
output: FileResponse<string>
subdir?: boolean
data?: Record<string, any>
data?: Record<string, unknown>
overwrite?: FileResponse<boolean>
quiet?: boolean
verbose?: LogLevel
logLevel?: LogLevel
dryRun?: boolean
helpers?: Record<string, Helper>
subdirHelper?: DefaultHelpers | string
inputs?: Record<string, ScaffoldInput>
beforeWrite?(
content: Buffer,
rawContent: Buffer,
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
afterScaffold?: AfterScaffoldHook
}
interface ScaffoldInput {
type?: "text" | "select" | "confirm" | "number"
message?: string
required?: boolean
default?: string | boolean | number
options?: (string | { name: string; value: string })[] // for type: "select"
}
interface AfterScaffoldContext {
config: ScaffoldConfig
files: string[] // absolute paths of written files
}
type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
```
### Before Write option
@@ -46,6 +59,69 @@ to be used as the file contents.
Returning `undefined` will keep the file contents as-is, after normal Handlebars.js procesing by
Simple Scaffold.
### After Scaffold hook
The `afterScaffold` option runs after all files have been written. It receives a context object with
the resolved config and the list of files that were created.
```typescript
import Scaffold from "simple-scaffold"
await Scaffold({
name: "my-app",
templates: ["templates/app"],
output: ".",
afterScaffold: async ({ config, files }) => {
console.log(`Created ${files.length} files`)
// e.g. run npm install, git init, open editor, etc.
},
})
```
You can also pass a shell command string, which will be executed in the output directory:
```typescript
await Scaffold({
name: "my-app",
templates: ["templates/app"],
output: "my-app",
afterScaffold: "npm install && git init",
})
```
In dry-run mode, the hook is still called but the `files` array will be empty.
### Inputs
The `inputs` option lets you define fields that will be prompted interactively (when running in a
TTY) and merged into the template data. This is useful when your templates need user-specific
values.
```typescript
import Scaffold from "simple-scaffold"
await Scaffold({
name: "component",
templates: ["templates/component"],
output: "src/components",
inputs: {
author: { message: "Author name", required: true },
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
private: { type: "confirm", message: "Private package?", default: false },
port: { type: "number", message: "Dev server port", default: 3000 },
},
})
// In templates: {{ author }}, {{ license }}
```
- **Required** inputs are prompted if not already in `data`
- **Optional** inputs with a `default` are applied silently
- Pre-providing values in `data` skips the prompt for that input
## Example
This is an example of loading a complete scaffold via Node.js:
@@ -53,7 +129,7 @@ This is an example of loading a complete scaffold via Node.js:
```typescript
import Scaffold from "simple-scaffold"
const config = {
await Scaffold({
name: "component",
templates: [path.join(__dirname, "scaffolds", "component")],
output: path.join(__dirname, "src", "components"),
@@ -65,10 +141,12 @@ const config = {
helpers: {
twice: (text) => [text, text].join(" "),
},
inputs: {
author: { message: "Author name", required: true },
license: { message: "License", default: "MIT" },
},
// return a string to replace the final file contents after pre-processing, or `undefined`
// to keep it as-is
beforeWrite: (content, rawContent, outputPath) => content.toString().toUpperCase(),
}
const scaffold = Scaffold(config)
})
```

View File

@@ -31,7 +31,6 @@ title: Examples
### Output
- Output file path:
- With `subdir = false` (default):
```text

View File

@@ -1,10 +1,11 @@
---
title: Usage
sidebar_position: 0
---
- [CLI Usage](cli)
- [Template Files](templates)
- [Configuration Files](configuration_files)
- [CLI Usage](cli)
- [Node.js Usage](node)
- [Examples](examples)
- [Migration](migration)
- [Node.js Usage](node)
- [Template Files](templates)

View File

@@ -19,7 +19,12 @@ const config: Config = {
projectName: "simple-scaffold", // Usually your repo name.
onBrokenLinks: "warn",
onBrokenMarkdownLinks: "warn",
markdown: {
hooks: {
onBrokenMarkdownLinks: "warn",
},
},
// Even if you don't use internationalization, you can use this field to set
// useful metadata like html lang. For example, if your site is Chinese, you
@@ -47,8 +52,12 @@ const config: Config = {
categorizeByGroup: false,
sort: ["visibility"],
categoryOrder: ["Main", "*"],
media: "media",
entryPointStrategy: "expand",
pageTitleTemplates: {
index: "{projectName}",
member: "`{rawName}`",
module: "{name}",
},
validation: {
invalidLink: true,
},
@@ -134,8 +143,12 @@ const config: Config = {
title: "Docs",
items: [
{
label: "Tutorial",
to: "/docs/intro",
label: "Usage",
to: "/docs/usage",
},
{
label: "API",
to: "/docs/api",
},
],
},

View File

@@ -15,23 +15,23 @@
"typecheck": "tsc"
},
"dependencies": {
"@docusaurus/core": "3.1.1",
"@docusaurus/plugin-google-tag-manager": "^3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^2.1.0",
"prism-react-renderer": "^2.3.1",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-google-tag-manager": "^3.9.2",
"@docusaurus/preset-classic": "^3.9.2",
"@mdx-js/react": "^3.1.1",
"clsx": "^2.1.1",
"prism-react-renderer": "^2.4.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "3.1.1",
"@docusaurus/tsconfig": "3.1.1",
"@docusaurus/types": "3.1.1",
"docusaurus-plugin-typedoc": "^0.22.0",
"typedoc": "^0.25.7",
"typedoc-plugin-markdown": "^3.17.1",
"typescript": "~5.2.2"
"@docusaurus/module-type-aliases": "^3.9.2",
"@docusaurus/tsconfig": "^3.9.2",
"@docusaurus/types": "^3.9.2",
"docusaurus-plugin-typedoc": "^1.4.2",
"typedoc": "^0.28.18",
"typedoc-plugin-markdown": "^4.11.0",
"typescript": "~5.9.3"
},
"browserslist": {
"production": [

10217
docs/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,51 +1,28 @@
import type { SidebarsConfig } from "@docusaurus/plugin-content-docs"
/**
* Creating a sidebar enables you to:
- create an ordered group of docs
- render a sidebar for each doc of that group
- provide next/previous navigation
The sidebars can be generated from the filesystem, or explicitly defined here.
Create as many sidebars as you want.
*/
const sidebars: SidebarsConfig = {
// By default, Docusaurus generates a sidebar from the docs folder structure
// docs: [{ type: "autogenerated", dirName: "." }],
usage: ["usage/index"],
api: ["api/index"],
docs: [{ type: "autogenerated", dirName: "." }],
// docs: [
// {
// type: "category",
// label: "Guides",
// link: {
// type: "generated-index",
// title: "Docusaurus Guides",
// description: "Learn about the most important Docusaurus concepts!",
// slug: "/category/docusaurus-guides",
// keywords: ["guides"],
// image: "/img/docusaurus.png",
// },
// items: ["pages", "docs", "blog", "search"],
// },
// ],
// usage: [{ type: "autogenerated", dirName: "usage" }],
// api: [{ type: "autogenerated", dirName: "api" }],
// But you can create a sidebar manually
/*
tutorialSidebar: [
'intro',
'hello',
usage: [{ type: "autogenerated", dirName: "usage" }],
api: [
{ type: "doc", id: "api/index", label: "Overview" },
{
type: 'category',
label: 'Tutorial',
items: ['tutorial-basics/create-a-document'],
type: "category",
label: "Functions",
items: [{ type: "autogenerated", dirName: "api/functions" }],
},
{
type: "category",
label: "Types",
items: [
{ type: "autogenerated", dirName: "api/interfaces" },
{ type: "autogenerated", dirName: "api/type-aliases" },
],
},
{
type: "category",
label: "Variables",
items: [{ type: "autogenerated", dirName: "api/variables" }],
},
],
*/
}
export default sidebars

View File

@@ -1,202 +0,0 @@
/*
* For a detailed explanation regarding each configuration property and type check, visit:
* https://jestjs.io/docs/configuration
*/
export default {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/private/var/folders/q9/0mns8fgd00b4t5j5lq2wh2yh0000gn/T/jest_dx",
// Automatically clear mock calls, instances, contexts and results before every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
collectCoverage: true,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: undefined,
// The directory where Jest should output its coverage files
coverageDirectory: "coverage",
// An array of regexp pattern strings used to skip coverage collection
coveragePathIgnorePatterns: ["/node_modules/", "scaffold.config.js"],
// Indicates which provider should be used to instrument code for coverage
coverageProvider: "v8",
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: undefined,
// A path to a custom dependency extractor
// dependencyExtractor: undefined,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// The default configuration for fake timers
// fakeTimers: {
// "enableGlobally": false
// },
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: undefined,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: undefined,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "mjs",
// "cjs",
// "jsx",
// "ts",
// "tsx",
// "json",
// "node"
// ],
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// moduleNameMapper: {
// "#ansi-styles": "<rootDir>/node_modules/chalk/source/vendor/ansi-styles/index.js",
// "#supports-color": "<rootDir>/node_modules/chalk/source/vendor/supports-color/index.js",
// },
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
modulePathIgnorePatterns: ["<rootDir>/dist"],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
preset: "ts-jest",
// Run tests from one or more projects
// projects: undefined,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state before every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: undefined,
// Automatically restore mock state and implementation before every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
// rootDir: undefined,
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// The number of seconds after which a test is considered as slow and reported as such in the results.
// slowTestThreshold: 5,
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
// testEnvironment: "jest-environment-node",
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: undefined,
// This option allows use of a custom test runner
// testRunner: "jest-circus/runner",
// A map from regular expressions to paths to transformers
// transform: undefined,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/",
// "\\.pnp\\.[^\\/]+$"
// ],
// transform: {
// "^.+\\.ts?$": "ts-jest",
// },
// transformIgnorePatterns: ["<rootDir>/node_modules/"],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
verbose: true,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
}

View File

@@ -1,6 +1,6 @@
{
"name": "simple-scaffold",
"version": "2.3.3",
"version": "3.1.0",
"description": "Generate any file structure - from single components to entire app boilerplates, with a single command.",
"homepage": "https://chenasraf.github.io/simple-scaffold",
"repository": {
@@ -27,34 +27,56 @@
],
"scripts": {
"clean": "rimraf dist",
"build": "pnpm clean && tsc && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/",
"dev": "tsc --watch",
"start": "ts-node src/scaffold.ts",
"test": "jest",
"coverage": "open coverage/lcov-report/index.html",
"cmd": "ts-node src/cmd.ts",
"build": "pnpm clean && vite build && tsc --emitDeclarationOnly && chmod -R +x ./dist && cp ./package.json ./README.md ./dist/",
"dev": "vite build --watch",
"start": "vite-node src/scaffold.ts",
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage && open coverage/lcov-report/index.html",
"cmd": "vite-node src/cmd.ts",
"docs:build": "cd docs && pnpm build",
"docs:watch": "cd docs && pnpm start",
"audit-fix": "pnpm audit --fix",
"ci": "pnpm install --frozen-lockfile"
"ci": "pnpm install --frozen-lockfile",
"lint": "eslint src/ tests/",
"format": "prettier --write .",
"lint-staged": "lint-staged",
"prepare": "husky"
},
"lint-staged": {
"*.{ts,mts,js,mjs}": [
"eslint --fix",
"prettier --write"
],
"*.{json,md,yml,yaml,css}": [
"prettier --write"
]
},
"dependencies": {
"@inquirer/confirm": "^6.0.10",
"@inquirer/input": "^5.0.10",
"@inquirer/number": "^4.0.10",
"@inquirer/select": "^5.1.2",
"date-fns": "^4.1.0",
"glob": "^11.0.3",
"glob": "^13.0.6",
"handlebars": "^4.7.8",
"massarg": "2.0.1"
"massarg": "2.1.1",
"minimatch": "^10.2.4",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@types/jest": "^30.0.0",
"@types/mock-fs": "^4.13.4",
"@types/node": "^24.0.3",
"jest": "^30.0.0",
"@eslint/js": "^10.0.1",
"@types/node": "^25.5.0",
"@vitest/coverage-v8": "^4.1.0",
"husky": "^9.1.7",
"lint-staged": "^16.4.0",
"mock-fs": "^5.5.0",
"rimraf": "^6.0.1",
"ts-jest": "^29.4.0",
"ts-node": "^10.9.2",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1"
"prettier": "^3.8.1",
"rimraf": "^6.1.3",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.1",
"vite": "^8.0.1",
"vite-node": "^6.0.0",
"vitest": "^4.1.0"
}
}

4233
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
// @ts-check
/** @type {import('./dist').ScaffoldConfigFile} */
module.exports = (conf) => {
console.log("Config:", conf)
module.exports = () => {
// console.log("Config:", conf)
return {
default: {
templates: ["examples/test-input/Component"],

86
src/before-write.ts Normal file
View File

@@ -0,0 +1,86 @@
import path from "node:path"
import fs from "node:fs/promises"
import { exec } from "node:child_process"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { log } from "./logger"
import { createDirIfNotExists, getUniqueTmpPath } from "./fs-utils"
/**
* Wraps a CLI beforeWrite command string into a beforeWrite callback function.
* The command receives the processed content via a temp file and can return modified content via stdout.
* @internal
*/
export function wrapBeforeWrite(
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
beforeWrite: string,
): ScaffoldConfig["beforeWrite"] {
return async (content, rawContent, outputFile) => {
const tmpDir = path.join(getUniqueTmpPath(), path.basename(outputFile))
await createDirIfNotExists(path.dirname(tmpDir), config)
const ext = path.extname(outputFile)
const rawTmpPath = tmpDir.replace(ext, ".raw" + ext)
try {
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
const cmd = await prepareBeforeWriteCmd({
beforeWrite,
tmpDir,
content,
rawTmpPath,
rawContent,
})
const result = await new Promise<string | undefined>((resolve, reject) => {
log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd)
const proc = exec(cmd)
proc.stdout!.on("data", (data) => {
if (data.trim()) {
resolve(data.toString())
} else {
resolve(undefined)
}
})
proc.stderr!.on("data", (data) => {
reject(data.toString())
})
})
return result
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.warning, "Error running beforeWrite command, returning original content")
return undefined
} finally {
await fs.rm(tmpDir, { force: true })
await fs.rm(rawTmpPath, { force: true })
}
}
}
async function prepareBeforeWriteCmd({
beforeWrite,
tmpDir,
content,
rawTmpPath,
rawContent,
}: {
beforeWrite: string
tmpDir: string
content: Buffer
rawTmpPath: string
rawContent: Buffer
}): Promise<string> {
let cmd: string = ""
const pathReg = /\{\{\s*path\s*\}\}/gi
const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi
if (pathReg.test(beforeWrite)) {
await fs.writeFile(tmpDir, content)
cmd = beforeWrite.replaceAll(pathReg, tmpDir)
}
if (rawPathReg.test(beforeWrite)) {
await fs.writeFile(rawTmpPath, rawContent)
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
}
if (!cmd) {
await fs.writeFile(tmpDir, content)
cmd = [beforeWrite, tmpDir].join(" ")
}
return cmd
}

View File

@@ -3,23 +3,24 @@
import path from "node:path"
import fs from "node:fs/promises"
import { massarg } from "massarg"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig } from "./types"
import { ListCommandCliOptions, LogLevel, ScaffoldCmdConfig, ScaffoldConfigMap } from "./types"
import { Scaffold } from "./scaffold"
import { getConfigFile, parseAppendData, parseConfigFile } from "./config"
import { findConfigFile, getConfigFile, parseAppendData, parseConfigFile } from "./config"
import { log } from "./logger"
import { MassargCommand } from "massarg/command"
import { getUniqueTmpPath as generateUniqueTmpPath } from "./file"
import { colorize } from "./utils"
import { colorize } from "./colors"
import { promptBeforeConfig, promptAfterConfig, resolveInputs } from "./prompts"
import { initScaffold } from "./init"
export async function parseCliArgs(args = process.argv.slice(2)) {
const isProjectRoot = Boolean(await fs.stat(path.join(__dirname, "package.json")).catch(() => false))
const pkgFile = await fs.readFile(path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"))
const isProjectRoot = Boolean(
await fs.stat(path.join(__dirname, "package.json")).catch(() => false),
)
const pkgFile = await fs.readFile(
path.resolve(__dirname, isProjectRoot ? "." : "..", "package.json"),
)
const pkg = JSON.parse(pkgFile.toString())
const isVersionFlag = args.includes("--version") || args.includes("-v")
const isConfigFileProvided = args.includes("--config") || args.includes("-c")
const isGitProvided = args.includes("--git") || args.includes("-g")
const isConfigProvided = isConfigFileProvided || isGitProvided || isVersionFlag
return massarg<ScaffoldCmdConfig>({
name: pkg.name,
description: pkg.description,
@@ -29,18 +30,46 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
console.log(pkg.version)
return
}
log(config, LogLevel.info, `Simple Scaffold v${pkg.version}`)
log(config, LogLevel.debug, `Simple Scaffold v${pkg.version}`)
config.tmpDir = generateUniqueTmpPath()
try {
// Auto-detect config file in cwd — but only if the user didn't
// explicitly provide templates/output (which signals a one-time run)
const isOneTimeRun = config.templates?.length > 0 || config.output
if (!config.config && !config.git && !isOneTimeRun) {
try {
config.config = await findConfigFile(process.cwd())
log(config, LogLevel.debug, `Auto-detected config file: ${config.config}`)
} catch {
// No config file found — that's fine, continue without one
}
}
// Load config map early so we can prompt for name and template key
const hasConfigSource = Boolean(config.config || config.git)
let configMap: ScaffoldConfigMap | undefined
if (hasConfigSource) {
configMap = await getConfigFile(config)
}
// Phase 1: prompt for name + key (needed before parseConfigFile)
config = await promptBeforeConfig(config, configMap)
// Parse and merge the config file
log(config, LogLevel.debug, "Parsing config file...", config)
const parsed = await parseConfigFile(config)
await Scaffold(parsed)
// Phase 2: prompt for anything still missing after config merge
const prompted = await promptAfterConfig(parsed)
const resolved = await resolveInputs(prompted)
await Scaffold(resolved)
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
log(config, LogLevel.error, message)
} finally {
log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir)
await fs.rm(config.tmpDir, { recursive: true, force: true })
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
}
})
.option({
@@ -49,9 +78,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
description:
"Name to be passed to the generated files. `{{name}}` and other data parameters inside " +
"contents and file names will be replaced accordingly. You may omit the `--name` or `-n` " +
"for this specific option.",
"for this specific option. If omitted in an interactive terminal, you will be prompted.",
isDefault: true,
required: !isConfigProvided,
})
.option({
name: "config",
@@ -68,15 +96,15 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
aliases: ["k"],
description:
"Key to load inside the config file. This overwrites the config key provided after the colon in `--config` " +
"(e.g. `--config scaffold.cmd.js:component)`",
"(e.g. `--config scaffold.cmd.js:component)`. If omitted and multiple templates are available, " +
"you will be prompted to select one.",
})
.option({
name: "output",
aliases: ["o"],
description:
"Path to output to. If `--subdir` is enabled, the subdir will be created inside " +
"this path. Default is current working directory.",
required: !isConfigProvided,
"this path. If omitted in an interactive terminal, you will be prompted.",
})
.option({
name: "templates",
@@ -85,8 +113,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
description:
"Template files to use as input. You may provide multiple files, each of which can be a relative or " +
"absolute path, " +
"or a glob pattern for multiple file matching easily.",
required: !isConfigProvided,
"or a glob pattern for multiple file matching easily. If omitted in an interactive terminal, " +
"you will be prompted for a comma-separated list.",
})
.flag({
name: "overwrite",
@@ -139,7 +167,9 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
parse: (v) => {
const val = v.toLowerCase()
if (!(val in LogLevel)) {
throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`)
throw new Error(
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
)
}
return val
},
@@ -152,6 +182,14 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
" file. A temporary file path will be passed to the given command and the command should " +
"return a string for the final output.",
})
.option({
name: "after-scaffold",
aliases: ["A"],
description:
"Run a shell command after all files have been written. " +
"The command is executed in the output directory. " +
"For example: `--after-scaffold 'npm install'`",
})
.flag({
name: "dry-run",
aliases: ["dr"],
@@ -169,7 +207,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
new MassargCommand<ListCommandCliOptions>({
name: "list",
aliases: ["ls"],
description: "List all available templates for a given config. See `list -h` for more information.",
description:
"List all available templates for a given config. See `list -h` for more information.",
run: async (_config) => {
const config = {
templates: [],
@@ -192,14 +231,15 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
log(config, LogLevel.error, message)
} finally {
log(config, LogLevel.debug, "Cleaning up temporary files...", config.tmpDir)
await fs.rm(config.tmpDir, { recursive: true, force: true })
if (config.tmpDir) await fs.rm(config.tmpDir, { recursive: true, force: true })
}
},
})
.option({
name: "config",
aliases: ["c"],
description: "Filename or directory to load config from. Defaults to current working directory.",
description:
"Filename or directory to load config from. Defaults to current working directory.",
})
.option({
name: "git",
@@ -217,7 +257,9 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
parse: (v) => {
const val = v.toLowerCase()
if (!(val in LogLevel)) {
throw new Error(`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`)
throw new Error(
`Invalid log level: ${val}, must be one of: ${Object.keys(LogLevel).join(", ")}`,
)
}
return val
},
@@ -226,6 +268,38 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
bindOption: true,
}),
)
.command(
new MassargCommand<{ dir?: string; format?: string }>({
name: "init",
aliases: [],
description:
"Initialize a new scaffold config file and example template in the current directory.",
run: async (config) => {
try {
await initScaffold({
dir: config.dir,
format: config.format as "js" | "mjs" | "json" | undefined,
})
} catch (e) {
const message = "message" in (e as object) ? (e as Error).message : e?.toString()
console.error(colorize.red(message ?? "Unknown error"))
}
},
})
.option({
name: "dir",
aliases: ["d"],
description: "Directory to create the config in. Defaults to current working directory.",
})
.option({
name: "format",
aliases: ["f"],
description: "Config file format: js, mjs, or json. If omitted, you will be prompted.",
})
.help({
bindOption: true,
}),
)
.example({
description: "Usage with config file",
input: "simple-scaffold -c scaffold.cmd.js --key component",
@@ -236,7 +310,8 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
})
.example({
description: "Usage with https git URL (for non-GitHub)",
input: "simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
input:
"simple-scaffold -g https://example.com/user/template.git -c scaffold.cmd.js --key component",
})
.example({
description: "Excluded template key, assumes 'default' key",
@@ -251,7 +326,11 @@ export async function parseCliArgs(args = process.argv.slice(2)) {
bindOption: true,
lineLength: 100,
useGlobalTableColumns: true,
usageText: [colorize.yellow`simple-scaffold`, colorize.gray`[options]`, colorize.cyan`<name>`].join(" "),
usageText: [
colorize.yellow`simple-scaffold`,
colorize.gray`[options]`,
colorize.cyan`<name>`,
].join(" "),
optionOptions: {
displayNegations: true,
},

74
src/colors.ts Normal file
View File

@@ -0,0 +1,74 @@
/** ANSI color code mapping for terminal output. */
const colorMap = {
reset: 0,
dim: 2,
bold: 1,
italic: 3,
underline: 4,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90,
} as const
/** Available terminal color names. */
export type TermColor = keyof typeof colorMap
function _colorize(text: string, color: TermColor): string {
const c = colorMap[color]!
let r: number
if (c > 1 && c < 30) {
r = c + 20
} else if (c === 1) {
r = 23
} else {
r = 0
}
return `\x1b[${c}m${text}\x1b[${r}m`
}
function isTemplateStringArray(
template: TemplateStringsArray | unknown,
): template is TemplateStringsArray {
return Array.isArray(template) && typeof template[0] === "string"
}
const createColorize =
(color: TermColor) =>
(template: TemplateStringsArray | unknown, ...params: unknown[]): string => {
return isTemplateStringArray(template)
? _colorize(
(template as TemplateStringsArray).reduce(
(acc, str, i) => acc + str + (params[i] ?? ""),
"",
),
color,
)
: _colorize(String(template), color)
}
type TemplateStringsFn = ReturnType<typeof createColorize> & ((text: string) => string)
type TemplateStringsFns = { [key in TermColor]: TemplateStringsFn }
/**
* Colorize text for terminal output.
*
* Can be used as a function: `colorize("text", "red")`
* Or via named helpers: `colorize.red("text")` / `colorize.red\`template\``
*/
export const colorize: typeof _colorize & TemplateStringsFns = Object.assign(
_colorize,
Object.entries(colorMap).reduce(
(acc, [key]) => {
acc[key as TermColor] = createColorize(key as TermColor)
return acc
},
{} as Record<TermColor, TemplateStringsFn>,
),
)

View File

@@ -1,9 +1,6 @@
import path from "node:path"
import fs from "node:fs/promises"
import {
ConfigLoadConfig,
FileResponse,
FileResponseHandler,
LogConfig,
LogLevel,
RemoteConfigLoadConfig,
@@ -12,35 +9,19 @@ import {
ScaffoldConfigFile,
ScaffoldConfigMap,
} from "./types"
import { handlebarsParse } from "./parser"
import { log } from "./logger"
import { resolve, wrapNoopResolver } from "./utils"
import { getGitConfig } from "./git"
import { createDirIfNotExists, getUniqueTmpPath, isDir, pathExists } from "./file"
import { exec } from "node:child_process"
import { isDir, pathExists } from "./fs-utils"
import { wrapBeforeWrite } from "./before-write"
/** @internal */
export function getOptionValueForFile<T>(
config: ScaffoldConfig,
filePath: string,
fn: FileResponse<T>,
defaultValue?: T,
): T {
if (typeof fn !== "function") {
return defaultValue ?? (fn as T)
}
return (fn as FileResponseHandler<T>)(
filePath,
path.dirname(handlebarsParse(config, filePath, { asPath: true }).toString()),
path.basename(handlebarsParse(config, filePath, { asPath: true }).toString()),
)
}
// Re-export for backward compatibility (tests import from here)
export { getOptionValueForFile } from "./file"
/** @internal */
/** Parses CLI append-data syntax (`key=value` or `key:=jsonValue`) into a data object. @internal */
export function parseAppendData(value: string, options: ScaffoldCmdConfig): unknown {
const data = options.data ?? {}
const [key, val] = value.split(/:?=/)
// raw
if (value.includes(":=") && !val.includes(":=")) {
return { ...data, [key]: JSON.parse(val) }
}
@@ -48,13 +29,16 @@ export function parseAppendData(value: string, options: ScaffoldCmdConfig): unkn
}
function isWrappedWithQuotes(string: string): boolean {
return (string.startsWith('"') && string.endsWith('"')) || (string.startsWith("'") && string.endsWith("'"))
return (
(string.startsWith('"') && string.endsWith('"')) ||
(string.startsWith("'") && string.endsWith("'"))
)
}
/** @internal */
/** Loads and resolves a config file (local or remote). @internal */
export async function getConfigFile(config: ScaffoldCmdConfig): Promise<ScaffoldConfigMap> {
if (config.git && !config.git.includes("://")) {
log(config, LogLevel.info, `Loading config from GitHub ${config.git}`)
log(config, LogLevel.debug, `Loading config from GitHub ${config.git}`)
config.git = githubPartToUrl(config.git)
}
@@ -62,16 +46,19 @@ export async function getConfigFile(config: ScaffoldCmdConfig): Promise<Scaffold
const configFilename = config.config
const configPath = isGit ? config.git : configFilename
log(config, LogLevel.info, `Loading config from file ${configFilename}`)
log(config, LogLevel.debug, `Loading config from file ${configFilename}`)
const configPromise = await (isGit
? getRemoteConfig({ git: configPath, config: configFilename, logLevel: config.logLevel, tmpDir: config.tmpDir! })
? getRemoteConfig({
git: configPath,
config: configFilename,
logLevel: config.logLevel,
tmpDir: config.tmpDir!,
})
: getLocalConfig({ config: configFilename, logLevel: config.logLevel }))
// resolve the config
let configImport = await resolve(configPromise, config)
// If the config is a function or promise, return the output
if (typeof configImport.default === "function" || configImport.default instanceof Promise) {
log(config, LogLevel.debug, "Config is a function or promise, resolving...")
configImport = await resolve(configImport.default, config)
@@ -79,7 +66,10 @@ export async function getConfigFile(config: ScaffoldCmdConfig): Promise<Scaffold
return configImport
}
/** @internal */
/**
* Parses a CLI config into a full ScaffoldConfig by merging CLI args, config file values,
* and append-data overrides. @internal
*/
export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<ScaffoldConfig> {
let output: ScaffoldConfig = {
name: config.name,
@@ -125,8 +115,13 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
}
output.data = { ...output.data, ...config.appendData }
const cmdBeforeWrite = config.beforeWrite ? wrapBeforeWrite(config, config.beforeWrite) : undefined
const cmdBeforeWrite = config.beforeWrite
? wrapBeforeWrite(config, config.beforeWrite)
: undefined
output.beforeWrite = cmdBeforeWrite ?? output.beforeWrite
if (config.afterScaffold) {
output.afterScaffold = config.afterScaffold
}
if (!output.name) {
throw new Error("simple-scaffold: Missing required option: name")
@@ -136,7 +131,7 @@ export async function parseConfigFile(config: ScaffoldCmdConfig): Promise<Scaffo
return output
}
/** @internal */
/** Converts a GitHub shorthand (user/repo) to a full HTTPS git URL. @internal */
export function githubPartToUrl(part: string): string {
const gitUrl = new URL(`https://github.com/${part}`)
if (!gitUrl.pathname.endsWith(".git")) {
@@ -145,8 +140,10 @@ export function githubPartToUrl(part: string): string {
return gitUrl.toString()
}
/** @internal */
export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfig>): Promise<ScaffoldConfigFile> {
/** Loads a scaffold config from a local file or directory. @internal */
export async function getLocalConfig(
config: ConfigLoadConfig & Partial<LogConfig>,
): Promise<ScaffoldConfigFile> {
const { config: configFile, ...logConfig } = config as Required<typeof config>
const absolutePath = path.resolve(process.cwd(), configFile)
@@ -160,21 +157,25 @@ export async function getLocalConfig(config: ConfigLoadConfig & Partial<LogConfi
if (!exists) {
throw new Error(`Could not find config file in directory ${absolutePath}`)
}
log(logConfig, LogLevel.info, `Loading config from: ${path.resolve(absolutePath, file)}`)
log(logConfig, LogLevel.debug, `Loading config from: ${path.resolve(absolutePath, file)}`)
return wrapNoopResolver(import(path.resolve(absolutePath, file)))
}
log(logConfig, LogLevel.info, `Loading config from: ${absolutePath}`)
log(logConfig, LogLevel.debug, `Loading config from: ${absolutePath}`)
return wrapNoopResolver(import(absolutePath))
}
/** @internal */
/** Loads a scaffold config from a remote git repository. @internal */
export async function getRemoteConfig(
config: RemoteConfigLoadConfig & Partial<LogConfig>,
): Promise<ScaffoldConfigFile> {
const { config: configFile, git, tmpDir, ...logConfig } = config as Required<typeof config>
log(logConfig, LogLevel.info, `Loading config from remote ${git}, config file ${configFile || "<auto-detect>"}`)
log(
logConfig,
LogLevel.debug,
`Loading config from remote ${git}, config file ${configFile || "<auto-detect>"}`,
)
const url = new URL(git!)
const isHttp = url.protocol === "http:" || url.protocol === "https:"
@@ -187,11 +188,12 @@ export async function getRemoteConfig(
return getGitConfig(url, configFile, tmpDir, logConfig)
}
/** @internal */
/** Searches for a scaffold config file in the given directory, trying known filenames in order. @internal */
export async function findConfigFile(root: string): Promise<string> {
const allowed = ["mjs", "cjs", "js", "json"].reduce((acc, ext) => {
acc.push(`scaffold.config.${ext}`)
acc.push(`scaffold.${ext}`)
acc.push(`.scaffold.${ext}`)
return acc
}, [] as string[])
for (const file of allowed) {
@@ -202,72 +204,3 @@ export async function findConfigFile(root: string): Promise<string> {
}
throw new Error(`Could not find config file in git repo`)
}
function wrapBeforeWrite(
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
beforeWrite: string,
): ScaffoldConfig["beforeWrite"] {
return async (content, rawContent, outputFile) => {
const tmpDir = path.join(getUniqueTmpPath(), path.basename(outputFile))
await createDirIfNotExists(path.dirname(tmpDir), config)
const ext = path.extname(outputFile)
const rawTmpPath = tmpDir.replace(ext, ".raw" + ext)
try {
log(config, LogLevel.debug, "Parsing beforeWrite command", beforeWrite)
const cmd = await prepareBeforeWriteCmd({ beforeWrite, tmpDir, content, rawTmpPath, rawContent })
const result = await new Promise<string | undefined>((resolve, reject) => {
log(config, LogLevel.debug, "Running parsed beforeWrite command:", cmd)
const proc = exec(cmd)
proc.stdout!.on("data", (data) => {
if (data.trim()) {
resolve(data.toString())
} else {
resolve(undefined)
}
})
proc.stderr!.on("data", (data) => {
reject(data.toString())
})
})
return result
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.warning, "Error running beforeWrite command, returning original content")
return undefined
} finally {
await fs.rm(tmpDir, { force: true })
await fs.rm(rawTmpPath, { force: true })
}
}
}
async function prepareBeforeWriteCmd({
beforeWrite,
tmpDir,
content,
rawTmpPath,
rawContent,
}: {
beforeWrite: string
tmpDir: string
content: Buffer
rawTmpPath: string
rawContent: Buffer
}): Promise<string> {
let cmd: string = ""
const pathReg = /\{\{\s*path\s*\}\}/gi
const rawPathReg = /\{\{\s*rawpath\s*\}\}/gi
if (pathReg.test(beforeWrite)) {
await fs.writeFile(tmpDir, content)
cmd = beforeWrite.replaceAll(pathReg, tmpDir)
}
if (rawPathReg.test(beforeWrite)) {
await fs.writeFile(rawTmpPath, rawContent)
cmd = beforeWrite.replaceAll(rawPathReg, rawTmpPath)
}
if (!cmd) {
await fs.writeFile(tmpDir, content)
cmd = [beforeWrite, tmpDir].join(" ")
}
return cmd
}

View File

@@ -1,76 +1,55 @@
import os from "node:os"
import path from "node:path"
import fs from "node:fs/promises"
import { F_OK } from "node:constants"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { FileResponse, FileResponseHandler, LogLevel, ScaffoldConfig } from "./types"
import { glob, hasMagic } from "glob"
import { log } from "./logger"
import { getOptionValueForFile } from "./config"
import { handlebarsParse } from "./parser"
import { handleErr } from "./utils"
import { createDirIfNotExists, pathExists, isDir } from "./fs-utils"
import { removeGlob } from "./path-utils"
const { stat, access, mkdir, readFile, writeFile } = fs
const { readFile, writeFile } = fs
export async function createDirIfNotExists(
dir: string,
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
): Promise<void> {
if (config.dryRun) {
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
return
}
const parentDir = path.dirname(dir)
if (!(await pathExists(parentDir))) {
await createDirIfNotExists(parentDir, config)
}
if (!(await pathExists(dir))) {
try {
log(config, LogLevel.debug, `Creating dir ${dir}`)
await mkdir(dir)
return
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code !== "EEXIST") {
throw e
}
return
}
// Re-export extracted utilities for backward compatibility (tests import from here)
export { createDirIfNotExists, pathExists, isDir, getUniqueTmpPath } from "./fs-utils"
export { removeGlob, makeRelativePath, getBasePath } from "./path-utils"
/**
* Resolves a config option that may be either a static value or a per-file function.
* For function values, the file path is parsed through Handlebars before being passed.
* @internal
*/
export function getOptionValueForFile<T>(
config: ScaffoldConfig,
filePath: string,
fn: FileResponse<T>,
defaultValue?: T,
): T {
if (typeof fn !== "function") {
return defaultValue ?? (fn as T)
}
return (fn as FileResponseHandler<T>)(
filePath,
path.dirname(handlebarsParse(config, filePath, { asPath: true }).toString()),
path.basename(handlebarsParse(config, filePath, { asPath: true }).toString()),
)
}
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, F_OK)
return true
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code === "ENOENT") {
return false
}
throw e
}
}
export async function isDir(path: string): Promise<boolean> {
const tplStat = await stat(path)
return tplStat.isDirectory()
}
export function removeGlob(template: string): string {
return path.normalize(template.replace(/\*/g, ""))
}
export function makeRelativePath(str: string): string {
return str.startsWith(path.sep) ? str.slice(1) : str
}
export function getBasePath(relPath: string): string {
return path
.resolve(process.cwd(), relPath)
.replace(process.cwd() + path.sep, "")
.replace(process.cwd(), "")
/** Information about a template glob pattern and how it was resolved. */
export interface GlobInfo {
/** The template path with glob wildcards stripped. */
baseTemplatePath: string
/** The original template string as provided by the user. */
origTemplate: string
/** Whether the template is a directory or contains glob patterns. */
isDirOrGlob: boolean
/** Whether the template contains glob wildcard characters. */
isGlob: boolean
/** The final resolved template path (with `**\/*` appended for directories). */
template: string
}
/** Expands a list of glob patterns into a flat list of matching file paths. */
export async function getFileList(config: ScaffoldConfig, templates: string[]): Promise<string[]> {
log(config, LogLevel.debug, `Getting file list for glob list: ${templates}`)
return (
@@ -81,30 +60,34 @@ export async function getFileList(config: ScaffoldConfig, templates: string[]):
).map(path.normalize)
}
export interface GlobInfo {
nonGlobTemplate: string
origTemplate: string
isDirOrGlob: boolean
isGlob: boolean
template: string
}
/** Analyzes a template path to determine if it's a glob, directory, or single file. */
export async function getTemplateGlobInfo(
config: ScaffoldConfig,
template: string,
): Promise<GlobInfo> {
const _isGlob = hasMagic(template)
log(config, LogLevel.debug, "before isDir", "isGlob:", _isGlob, template)
export async function getTemplateGlobInfo(config: ScaffoldConfig, template: string): Promise<GlobInfo> {
const isGlob = hasMagic(template)
log(config, LogLevel.debug, "before isDir", "isGlob:", isGlob, template)
let _template = template
let nonGlobTemplate = isGlob ? removeGlob(template) : template
nonGlobTemplate = path.normalize(nonGlobTemplate)
const isDirOrGlob = isGlob ? true : await isDir(template)
const _shouldAddGlob = !isGlob && isDirOrGlob
log(config, LogLevel.debug, "after", { isDirOrGlob, _shouldAddGlob })
const origTemplate = template
if (_shouldAddGlob) {
_template = path.join(template, "**", "*")
let resolvedTemplate = template
let baseTemplatePath = _isGlob ? removeGlob(template) : template
baseTemplatePath = path.normalize(baseTemplatePath)
const isDirOrGlob = _isGlob ? true : await isDir(template)
const shouldAddGlob = !_isGlob && isDirOrGlob
log(config, LogLevel.debug, "after", { isDirOrGlob, shouldAddGlob })
if (shouldAddGlob) {
resolvedTemplate = path.join(template, "**", "*")
}
return {
baseTemplatePath,
origTemplate: template,
isDirOrGlob,
isGlob: _isGlob,
template: resolvedTemplate,
}
return { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template: _template }
}
/** Complete information about a template file's output destination. */
export interface OutputFileInfo {
inputPath: string
outputPathOpt: string
@@ -113,6 +96,7 @@ export interface OutputFileInfo {
exists: boolean
}
/** Computes the full output path and metadata for a single template file. */
export async function getTemplateFileInfo(
config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string },
@@ -126,6 +110,10 @@ export async function getTemplateFileInfo(
return { inputPath, outputPathOpt, outputDir, outputPath, exists }
}
/**
* Reads a template file, applies Handlebars parsing, runs the beforeWrite hook,
* and writes the result to the output path.
*/
export async function copyFileTransformed(
config: ScaffoldConfig,
{
@@ -142,27 +130,32 @@ export async function copyFileTransformed(
): Promise<void> {
if (!exists || overwrite) {
if (exists && overwrite) {
log(config, LogLevel.info, `File ${outputPath} exists, overwriting`)
log(config, LogLevel.debug, `Overwriting ${outputPath}`)
}
log(config, LogLevel.debug, `Processing file ${inputPath}`)
const templateBuffer = await readFile(inputPath)
const unprocessedOutputContents = handlebarsParse(config, templateBuffer)
const finalOutputContents =
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ?? unprocessedOutputContents
(await config.beforeWrite?.(unprocessedOutputContents, templateBuffer, outputPath)) ??
unprocessedOutputContents
if (!config.dryRun) {
await writeFile(outputPath, finalOutputContents)
} else {
log(config, LogLevel.info, "Dry Run. Output should be:")
log(config, LogLevel.info, finalOutputContents.toString())
log(config, LogLevel.debug, "Dry run — output would be:")
log(config, LogLevel.debug, finalOutputContents.toString())
}
} else if (exists) {
log(config, LogLevel.info, `File ${outputPath} already exists, skipping`)
log(config, LogLevel.debug, `Skipped ${outputPath} (already exists)`)
}
log(config, LogLevel.info, "Done.")
}
export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, basePath: string): string {
/** Computes the output directory for a file, combining the output path, base path, and optional subdir. */
export function getOutputDir(
config: ScaffoldConfig,
outputPathOpt: string,
basePath: string,
): string {
return path.resolve(
process.cwd(),
...([
@@ -177,15 +170,23 @@ export function getOutputDir(config: ScaffoldConfig, outputPathOpt: string, base
)
}
/**
* Processes a single template file: resolves output paths, creates directories,
* and writes the transformed output.
* Returns the output path if the file was written, or null if skipped.
*/
export async function handleTemplateFile(
config: ScaffoldConfig,
{ templatePath, basePath }: { templatePath: string; basePath: string },
): Promise<void> {
): Promise<string | null> {
try {
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(config, {
templatePath,
basePath,
})
const { inputPath, outputPathOpt, outputDir, outputPath, exists } = await getTemplateFileInfo(
config,
{
templatePath,
basePath,
},
)
const overwrite = getOptionValueForFile(config, inputPath, config.overwrite ?? false)
log(
@@ -202,15 +203,12 @@ export async function handleTemplateFile(
await createDirIfNotExists(path.dirname(outputPath), config)
log(config, LogLevel.info, `Writing to ${outputPath}`)
const shouldWrite = (!exists || overwrite) && !config.dryRun
log(config, LogLevel.debug, `Writing to ${outputPath}`)
await copyFileTransformed(config, { exists, overwrite, outputPath, inputPath })
return shouldWrite ? outputPath : null
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
throw e
}
}
/** @internal */
export function getUniqueTmpPath(): string {
return path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`)
}

64
src/fs-utils.ts Normal file
View File

@@ -0,0 +1,64 @@
import os from "node:os"
import path from "node:path"
import fs from "node:fs/promises"
import { F_OK } from "node:constants"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { log } from "./logger"
const { stat, access, mkdir } = fs
/** Recursively creates a directory and its parents if they don't exist. */
export async function createDirIfNotExists(
dir: string,
config: LogConfig & Pick<ScaffoldConfig, "dryRun">,
): Promise<void> {
if (config.dryRun) {
log(config, LogLevel.info, `Dry Run. Not creating dir ${dir}`)
return
}
const parentDir = path.dirname(dir)
if (!(await pathExists(parentDir))) {
await createDirIfNotExists(parentDir, config)
}
if (!(await pathExists(dir))) {
try {
log(config, LogLevel.debug, `Creating dir ${dir}`)
await mkdir(dir)
return
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code !== "EEXIST") {
throw e
}
return
}
}
}
/** Checks whether a file or directory exists at the given path. */
export async function pathExists(filePath: string): Promise<boolean> {
try {
await access(filePath, F_OK)
return true
} catch (e: unknown) {
if (e && (e as NodeJS.ErrnoException).code === "ENOENT") {
return false
}
throw e
}
}
/** Returns true if the given path is a directory. */
export async function isDir(dirPath: string): Promise<boolean> {
const tplStat = await stat(dirPath)
return tplStat.isDirectory()
}
/** Generates a unique temporary directory path for scaffold operations. @internal */
export function getUniqueTmpPath(): string {
return path.resolve(
os.tmpdir(),
`scaffold-config-${Date.now()}-${Math.random().toString(36).slice(2)}`,
)
}

View File

@@ -13,7 +13,7 @@ export async function getGitConfig(
): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
const repoUrl = `${url.protocol}//${url.host}${url.pathname}`
log(logConfig, LogLevel.info, `Cloning git repo ${repoUrl}`)
log(logConfig, LogLevel.debug, `Cloning git repo ${repoUrl}`)
return new Promise((res, reject) => {
log(logConfig, LogLevel.debug, `Cloning git repo to ${tmpPath}`)
@@ -43,13 +43,16 @@ export async function loadGitConfig({
file: string
tmpPath: string
}): Promise<AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>> {
log(logConfig, LogLevel.info, `Loading config from git repo: ${repoUrl}`)
log(logConfig, LogLevel.debug, `Loading config from git repo: ${repoUrl}`)
const filename = file || (await findConfigFile(tmpPath))
const absolutePath = path.resolve(tmpPath, filename)
log(logConfig, LogLevel.debug, `Resolving config file: ${absolutePath}`)
const loadedConfig = await resolve(async () => (await import(absolutePath)).default as ScaffoldConfigMap, logConfig)
const loadedConfig = await resolve(
async () => (await import(absolutePath)).default as ScaffoldConfigMap,
logConfig,
)
log(logConfig, LogLevel.info, `Loaded config from git`)
log(logConfig, LogLevel.debug, `Loaded config from git`)
log(logConfig, LogLevel.debug, `Raw config:`, loadedConfig)
const fixedConfig: ScaffoldConfigMap = {}
for (const [k, v] of Object.entries(loadedConfig)) {

67
src/ignore.ts Normal file
View File

@@ -0,0 +1,67 @@
import path from "node:path"
import fs from "node:fs/promises"
import { minimatch } from "minimatch"
import { pathExists } from "./fs-utils"
const IGNORE_FILENAME = ".scaffoldignore"
/**
* Reads a `.scaffoldignore` file from the given directory and returns
* the parsed patterns for filtering.
*
* Lines starting with `#` are comments. Empty lines are skipped.
*
* @param dir The directory to search for `.scaffoldignore`
* @returns Array of glob patterns to ignore
*/
export async function loadIgnorePatterns(dir: string): Promise<string[]> {
const ignorePath = path.resolve(dir, IGNORE_FILENAME)
if (!(await pathExists(ignorePath))) {
return []
}
const content = await fs.readFile(ignorePath, "utf-8")
return parseIgnoreFile(content)
}
/**
* Parses the contents of a `.scaffoldignore` file into glob patterns.
* @internal
*/
export function parseIgnoreFile(content: string): string[] {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"))
}
/**
* Filters a list of file paths, removing any that match the ignore patterns.
* Patterns are matched against the relative path from baseDir.
* Also always excludes `.scaffoldignore` itself.
*/
export function filterIgnoredFiles(
files: string[],
ignorePatterns: string[],
baseDir: string,
): string[] {
return files.filter((file) => {
const basename = path.basename(file)
if (basename === IGNORE_FILENAME) {
return false
}
const relPath = path.relative(baseDir, file)
for (const pattern of ignorePatterns) {
if (
minimatch(relPath, pattern, { dot: true }) ||
minimatch(basename, pattern, { dot: true })
) {
return false
}
}
return true
})
}

View File

@@ -1,5 +1,6 @@
export * from "./scaffold"
export * from "./types"
export { validateConfig, assertConfigValid, scaffoldConfigSchema } from "./validate"
import Scaffold from "./scaffold"
export default Scaffold

111
src/init.ts Normal file
View File

@@ -0,0 +1,111 @@
import path from "node:path"
import fs from "node:fs/promises"
import select from "@inquirer/select"
import { colorize } from "./colors"
import { pathExists } from "./fs-utils"
const CONFIG_EXTENSIONS = {
js: "scaffold.config.js",
mjs: "scaffold.config.mjs",
json: "scaffold.config.json",
} as const
type ConfigFormat = keyof typeof CONFIG_EXTENSIONS
const CONFIG_TEMPLATES: Record<ConfigFormat, string> = {
js: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
module.exports = {
default: {
templates: ["templates/default"],
output: ".",
// inputs: {
// author: { message: "Author name", required: true },
// license: { message: "License", default: "MIT" },
// },
},
}
`,
mjs: `/** @type {import('simple-scaffold').ScaffoldConfigMap} */
export default {
default: {
templates: ["templates/default"],
output: ".",
// inputs: {
// author: { message: "Author name", required: true },
// license: { message: "License", default: "MIT" },
// },
},
}
`,
json: `{
"default": {
"templates": ["templates/default"],
"output": "."
}
}
`,
}
const EXAMPLE_TEMPLATE_CONTENT = `# {{ name }}
Created by Simple Scaffold.
{{#if description}}{{ description }}{{/if}}
`
export interface InitOptions {
/** Working directory to create the config in. Defaults to cwd. */
dir?: string
/** Config format to use. If not provided, the user is prompted. */
format?: ConfigFormat
/** Whether to create an example template directory. Defaults to true. */
createExample?: boolean
}
/**
* Initializes a new Simple Scaffold project by creating a config file
* and an optional example template directory.
*/
export async function initScaffold(options: InitOptions = {}): Promise<void> {
const dir = options.dir ?? process.cwd()
const format =
options.format ??
(await select<ConfigFormat>({
message: colorize.cyan("Config file format:"),
choices: [
{ name: "JavaScript (CommonJS)", value: "js" },
{ name: "JavaScript (ESM)", value: "mjs" },
{ name: "JSON", value: "json" },
],
}))
const filename = CONFIG_EXTENSIONS[format]
const configPath = path.resolve(dir, filename)
if (await pathExists(configPath)) {
console.log(colorize.yellow(`${filename} already exists, skipping config creation.`))
} else {
await fs.writeFile(configPath, CONFIG_TEMPLATES[format])
console.log(colorize.green(`Created ${filename}`))
}
const createExample = options.createExample ?? true
if (createExample) {
const templateDir = path.resolve(dir, "templates", "default")
const templateFile = path.join(templateDir, "{{name}}.md")
if (await pathExists(templateDir)) {
console.log(colorize.yellow("templates/default/ already exists, skipping example template."))
} else {
await fs.mkdir(templateDir, { recursive: true })
await fs.writeFile(templateFile, EXAMPLE_TEMPLATE_CONTENT)
console.log(colorize.green("Created templates/default/{{name}}.md"))
}
}
console.log()
console.log(colorize.dim("Get started:"))
console.log(colorize.dim(` npx simple-scaffold MyProject`))
console.log()
}

View File

@@ -1,30 +1,38 @@
import util from "util"
import path from "node:path"
import { LogConfig, LogLevel, ScaffoldConfig } from "./types"
import { colorize, TermColor } from "./utils"
import { colorize, TermColor } from "./colors"
/** Priority ordering for log levels (higher = more severe). */
const LOG_PRIORITY: Record<LogLevel, number> = {
[LogLevel.none]: 0,
[LogLevel.debug]: 1,
[LogLevel.info]: 2,
[LogLevel.warning]: 3,
[LogLevel.error]: 4,
}
/** Maps each log level to a terminal color. */
const LOG_LEVEL_COLOR: Record<LogLevel, TermColor> = {
[LogLevel.none]: "reset",
[LogLevel.debug]: "dim",
[LogLevel.info]: "reset",
[LogLevel.warning]: "yellow",
[LogLevel.error]: "red",
}
/** Logs a message at the given level, respecting the configured log level filter. */
export function log(config: LogConfig, level: LogLevel, ...obj: unknown[]): void {
const priority: Record<LogLevel, number> = {
[LogLevel.none]: 0,
[LogLevel.debug]: 1,
[LogLevel.info]: 2,
[LogLevel.warning]: 3,
[LogLevel.error]: 4,
}
if (config.logLevel === LogLevel.none || priority[level] < priority[config.logLevel ?? LogLevel.info]) {
if (
config.logLevel === LogLevel.none ||
LOG_PRIORITY[level] < LOG_PRIORITY[config.logLevel ?? LogLevel.info]
) {
return
}
const levelColor: Record<keyof typeof LogLevel, TermColor> = {
[LogLevel.none]: "reset",
[LogLevel.debug]: "blue",
[LogLevel.info]: "dim",
[LogLevel.warning]: "yellow",
[LogLevel.error]: "red",
}
const colorFn = colorize[levelColor[level]]
const key: "log" | "warn" | "error" = level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
const colorFn = colorize[LOG_LEVEL_COLOR[level]]
const key: "log" | "warn" | "error" =
level === LogLevel.error ? "error" : level === LogLevel.warning ? "warn" : "log"
const logFn: (..._args: unknown[]) => void = console[key]
logFn(
...obj.map((i) =>
@@ -37,6 +45,10 @@ export function log(config: LogConfig, level: LogLevel, ...obj: unknown[]): void
)
}
/**
* Logs detailed file processing information at debug level.
* @deprecated Use `log(config, LogLevel.debug, data)` directly instead.
*/
export function logInputFile(
config: ScaffoldConfig,
data: {
@@ -53,6 +65,7 @@ export function logInputFile(
log(config, LogLevel.debug, data)
}
/** Logs the full scaffold configuration at debug level. */
export function logInitStep(config: ScaffoldConfig): void {
log(config, LogLevel.debug, "Full config:", {
name: config.name,
@@ -67,5 +80,56 @@ export function logInitStep(config: ScaffoldConfig): void {
dryRun: config.dryRun,
beforeWrite: config.beforeWrite,
} as Record<keyof ScaffoldConfig, unknown>)
log(config, LogLevel.info, "Data:", config.data)
}
/**
* Logs a tree of created files, grouped by directory.
*/
export function logFileTree(config: LogConfig, files: string[]): void {
if (files.length === 0) return
// Find common prefix to make paths relative
const commonDir = files.reduce((prefix, file) => {
while (!file.startsWith(prefix)) {
prefix = path.dirname(prefix)
}
return prefix
}, path.dirname(files[0]))
log(config, LogLevel.info, "")
log(config, LogLevel.info, colorize.bold(`📁 ${commonDir}`))
const relPaths = files.map((f) => path.relative(commonDir, f)).sort()
for (let i = 0; i < relPaths.length; i++) {
const isLast = i === relPaths.length - 1
const prefix = isLast ? "└── " : "├── "
log(config, LogLevel.info, colorize.dim(prefix) + relPaths[i])
}
}
/**
* Logs a final summary line with file count and elapsed time.
*/
export function logSummary(
config: LogConfig,
fileCount: number,
elapsedMs: number,
dryRun?: boolean,
): void {
const timeStr =
elapsedMs < 1000 ? `${Math.round(elapsedMs)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`
log(config, LogLevel.info, "")
if (dryRun) {
log(
config,
LogLevel.info,
colorize.yellow(`🏜️ Dry run complete — ${fileCount} file(s) would be created (${timeStr})`),
)
} else if (fileCount === 0) {
log(config, LogLevel.info, colorize.yellow(`⚠️ No files created (${timeStr})`))
} else {
log(config, LogLevel.info, colorize.green(`✅ Created ${fileCount} file(s) in ${timeStr}`))
}
}

View File

@@ -1,17 +1,10 @@
import path from "node:path"
import { DefaultHelpers, Helper, LogLevel, ScaffoldConfig } from "./types"
import Handlebars from "handlebars"
import dtAdd from "date-fns/add"
import dtFormat from "date-fns/format"
import dtParseISO from "date-fns/parseISO"
import { add, format, parseISO, type Duration } from "date-fns"
import { log } from "./logger"
import { Duration } from "date-fns"
const dateFns = {
add: dtAdd.add,
format: dtFormat.format,
parseISO: dtParseISO.parseISO,
}
const dateFns = { add, format, parseISO }
export const defaultHelpers: Record<DefaultHelpers, Helper> = {
camelCase,
@@ -27,7 +20,12 @@ export const defaultHelpers: Record<DefaultHelpers, Helper> = {
}
function _dateHelper(date: Date, formatString: string): string
function _dateHelper(date: Date, formatString: string, durationDifference: number, durationType: keyof Duration): string
function _dateHelper(
date: Date,
formatString: string,
durationDifference: number,
durationType: keyof Duration,
): string
function _dateHelper(
date: Date,
formatString: string,
@@ -41,8 +39,16 @@ function _dateHelper(
}
export function nowHelper(formatString: string): string
export function nowHelper(formatString: string, durationDifference: number, durationType: keyof Duration): string
export function nowHelper(formatString: string, durationDifference?: number, durationType?: keyof Duration): string {
export function nowHelper(
formatString: string,
durationDifference: number,
durationType: keyof Duration,
): string
export function nowHelper(
formatString: string,
durationDifference?: number,
durationType?: keyof Duration,
): string {
return _dateHelper(new Date(), formatString, durationDifference!, durationType!)
}
@@ -62,9 +68,16 @@ export function dateHelper(
return _dateHelper(dateFns.parseISO(date), formatString, durationDifference!, durationType!)
}
// splits by either non-alpha character or capital letter
// splits by either non-alphanumeric character or capital letter boundaries
function toWordParts(string: string): string[] {
return string.split(/(?=[A-Z])|[^a-zA-Z]/).filter((s) => s.length > 0)
// First split on non-alphanumeric characters
return string
.split(/[^a-zA-Z0-9]/)
.flatMap((segment) =>
// Then split camelCase/PascalCase boundaries, handling consecutive uppercase (e.g. "HTMLParser" -> "HTML", "Parser")
segment.split(/(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/),
)
.filter((s) => s.length > 0)
}
function camelCase(s: string): string {
@@ -121,7 +134,7 @@ export function handlebarsParse(
return Buffer.from(outputContents)
} catch (e) {
log(config, LogLevel.debug, e)
log(config, LogLevel.info, "Couldn't parse file with handlebars, returning original content")
log(config, LogLevel.debug, "Couldn't parse file with handlebars, returning original content")
return Buffer.from(templateBuffer)
}
}

19
src/path-utils.ts Normal file
View File

@@ -0,0 +1,19 @@
import path from "node:path"
/** Strips glob wildcard characters from a template path. */
export function removeGlob(template: string): string {
return path.normalize(template.replace(/\*/g, ""))
}
/** Removes a leading path separator, making the path relative. */
export function makeRelativePath(str: string): string {
return str.startsWith(path.sep) ? str.slice(1) : str
}
/** Computes a base path relative to the current working directory. */
export function getBasePath(relPath: string): string {
return path
.resolve(process.cwd(), relPath)
.replace(process.cwd() + path.sep, "")
.replace(process.cwd(), "")
}

253
src/prompts.ts Normal file
View File

@@ -0,0 +1,253 @@
import input from "@inquirer/input"
import select from "@inquirer/select"
import confirm from "@inquirer/confirm"
import number from "@inquirer/number"
import { colorize } from "./colors"
import {
ScaffoldCmdConfig,
ScaffoldConfig,
ScaffoldConfigMap,
ScaffoldInput,
ScaffoldInputType,
} from "./types"
/** Prompts the user for a scaffold name. */
export async function promptForName(): Promise<string> {
return input({
message: colorize.cyan("Scaffold name:"),
required: true,
validate: (value) => {
if (!value.trim()) return "Name cannot be empty"
return true
},
})
}
/** Prompts the user to select a template key from the available config keys. */
export async function promptForTemplateKey(configMap: ScaffoldConfigMap): Promise<string> {
const keys = Object.keys(configMap)
if (keys.length === 0) {
throw new Error("No templates found in config file")
}
if (keys.length === 1) {
return keys[0]
}
return select({
message: colorize.cyan("Select a template:"),
choices: keys.map((key) => ({
name: key,
value: key,
})),
})
}
/** Prompts the user for an output directory path. */
export async function promptForOutput(): Promise<string> {
return input({
message: colorize.cyan("Output directory:"),
required: true,
default: ".",
validate: (value) => {
if (!value.trim()) return "Output directory cannot be empty"
return true
},
})
}
/** Prompts the user for template paths (comma-separated). */
export async function promptForTemplates(): Promise<string[]> {
const value = await input({
message: colorize.cyan("Template paths (comma-separated):"),
required: true,
validate: (value) => {
if (!value.trim()) return "At least one template path is required"
return true
},
})
return value
.split(",")
.map((t) => t.trim())
.filter(Boolean)
}
/** Prompts for a single input based on its type. */
async function promptSingleInput(
key: string,
def: ScaffoldInput,
): Promise<string | boolean | number | undefined> {
const type: ScaffoldInputType = def.type ?? "text"
const message = colorize.cyan(def.message ?? `${key}:`)
switch (type) {
case "text":
return input({
message,
required: def.required,
default: def.default as string | undefined,
validate: def.required
? (value) => {
if (!value.trim()) return `${key} is required`
return true
}
: undefined,
})
case "select": {
const choices = (def.options ?? []).map((opt) =>
typeof opt === "string" ? { name: opt, value: opt } : opt,
)
if (choices.length === 0) {
throw new Error(`Input "${key}" has type "select" but no options defined`)
}
return select({
message,
choices,
default: def.default as string | undefined,
})
}
case "confirm":
return confirm({
message,
default: (def.default as boolean | undefined) ?? false,
})
case "number":
return (
(await number({
message,
required: def.required,
default: def.default as number | undefined,
})) ?? def.default
)
}
}
/**
* Prompts the user for any required scaffold inputs that are not already provided in data.
* Also applies default values for optional inputs that have one.
* Returns the merged data object.
*/
export async function promptForInputs(
inputs: Record<string, ScaffoldInput>,
existingData: Record<string, unknown> = {},
): Promise<Record<string, unknown>> {
const data = { ...existingData }
for (const [key, def] of Object.entries(inputs)) {
// Skip if already provided via data/CLI
if (key in data && data[key] !== undefined && data[key] !== "") {
continue
}
if (def.required || def.type === "select" || def.type === "confirm") {
data[key] = await promptSingleInput(key, def)
} else if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
return data
}
/** Returns true if the process is running in an interactive terminal. */
export function isInteractive(): boolean {
return Boolean(process.stdin.isTTY)
}
/**
* Prompts for name and template key before the config file is parsed.
* These are needed by parseConfigFile to know which template to load.
*/
export async function promptBeforeConfig(
config: ScaffoldCmdConfig,
configMap?: ScaffoldConfigMap,
): Promise<ScaffoldCmdConfig> {
if (!isInteractive()) {
return config
}
if (!config.name) {
config.name = await promptForName()
}
if (configMap && !config.key) {
const keys = Object.keys(configMap)
if (keys.length > 1) {
config.key = await promptForTemplateKey(configMap)
}
}
return config
}
/**
* Prompts for any values still missing after the config file has been parsed.
* Only prompts in interactive mode.
*/
export async function promptAfterConfig(config: ScaffoldConfig): Promise<ScaffoldConfig> {
if (!isInteractive()) {
return config
}
if (!config.output || (typeof config.output === "string" && !config.output)) {
config.output = await promptForOutput()
}
if (!config.templates || config.templates.length === 0) {
config.templates = await promptForTemplates()
}
return config
}
/**
* @deprecated Use {@link promptBeforeConfig} and {@link promptAfterConfig} instead.
*/
export async function promptForMissingConfig(
config: ScaffoldCmdConfig,
configMap?: ScaffoldConfigMap,
): Promise<ScaffoldCmdConfig> {
const afterPre = await promptBeforeConfig(config, configMap)
if (!isInteractive()) {
return afterPre
}
if (!afterPre.output) {
afterPre.output = await promptForOutput()
}
if (!afterPre.templates || afterPre.templates.length === 0) {
afterPre.templates = await promptForTemplates()
}
return afterPre
}
/**
* Prompts for any required inputs defined in the scaffold config and merges them into data.
* Only prompts in interactive mode; in non-interactive mode, only applies defaults.
*/
export async function resolveInputs(config: ScaffoldConfig): Promise<ScaffoldConfig> {
if (!config.inputs) {
return config
}
const interactive = isInteractive()
if (interactive) {
config.data = await promptForInputs(config.inputs, config.data)
} else {
// Non-interactive: only apply defaults
const data = { ...config.data }
for (const [key, def] of Object.entries(config.inputs)) {
if (def.default !== undefined && !(key in data)) {
data[key] = def.default
}
}
config.data = data
}
return config
}

View File

@@ -6,22 +6,18 @@
*/
import path from "node:path"
import os from "node:os"
import { exec } from "node:child_process"
import { handleErr, resolve } from "./utils"
import {
isDir,
removeGlob,
makeRelativePath,
getTemplateGlobInfo,
getFileList,
getBasePath,
handleTemplateFile,
GlobInfo,
} from "./file"
import { isDir, getTemplateGlobInfo, getFileList, handleTemplateFile, GlobInfo } from "./file"
import { removeGlob, makeRelativePath, getBasePath } from "./path-utils"
import { LogLevel, MinimalConfig, Resolver, ScaffoldCmdConfig, ScaffoldConfig } from "./types"
import { registerHelpers } from "./parser"
import { log, logInitStep, logInputFile } from "./logger"
import { log, logInitStep, logFileTree, logSummary } from "./logger"
import { parseConfigFile } from "./config"
import { resolveInputs } from "./prompts"
import { loadIgnorePatterns, filterIgnoredFiles } from "./ignore"
import { assertConfigValid } from "./validate"
/**
* Create a scaffold using given `options`.
@@ -58,53 +54,135 @@ import { parseConfigFile } from "./config"
export async function Scaffold(config: ScaffoldConfig): Promise<void> {
config.output ??= process.cwd()
await assertConfigValid(config)
config = await resolveInputs(config)
registerHelpers(config)
const startTime = performance.now()
const writtenFiles: string[] = []
try {
config.data = { name: config.name, ...config.data }
logInitStep(config)
log(config, LogLevel.info, `Scaffolding "${config.name}"...`)
const excludes = config.templates.filter((t) => t.startsWith("!"))
const includes = config.templates.filter((t) => !t.startsWith("!"))
const templates: GlobInfo[] = []
for (const includedTemplate of includes) {
try {
const { nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template } = await getTemplateGlobInfo(
config,
includedTemplate,
)
templates.push({ nonGlobTemplate, origTemplate, isDirOrGlob, isGlob, template })
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
}
}
const templates = await resolveTemplateGlobs(config, includes)
for (const tpl of templates) {
const files = await getFileList(config, [tpl.template, ...excludes])
for (const file of files) {
if (await isDir(file)) {
continue
}
log(config, LogLevel.debug, "Iterating files", { files, file })
const relPath = makeRelativePath(path.dirname(removeGlob(file).replace(tpl.nonGlobTemplate, "")))
const basePath = getBasePath(relPath)
logInputFile(config, {
originalTemplate: tpl.origTemplate,
relativePath: relPath,
parsedTemplate: tpl.template,
inputFilePath: file,
nonGlobTemplate: tpl.nonGlobTemplate,
basePath,
isDirOrGlob: tpl.isDirOrGlob,
isGlob: tpl.isGlob,
})
await handleTemplateFile(config, {
templatePath: file,
basePath,
})
}
const files = await processTemplateGlob(config, tpl, excludes)
writtenFiles.push(...files)
}
} catch (e: unknown) {
log(config, LogLevel.error, e)
throw e
}
const elapsed = performance.now() - startTime
logFileTree(config, writtenFiles)
logSummary(config, writtenFiles.length, elapsed, config.dryRun)
if (config.afterScaffold) {
await runAfterScaffoldHook(config, writtenFiles)
}
}
/** Resolves included template paths into GlobInfo objects. */
async function resolveTemplateGlobs(
config: ScaffoldConfig,
includes: string[],
): Promise<GlobInfo[]> {
const templates: GlobInfo[] = []
for (const includedTemplate of includes) {
try {
templates.push(await getTemplateGlobInfo(config, includedTemplate))
} catch (e: unknown) {
handleErr(e as NodeJS.ErrnoException)
}
}
return templates
}
/** Processes all files matching a single template glob pattern. Returns paths of written files. */
async function processTemplateGlob(
config: ScaffoldConfig,
tpl: GlobInfo,
excludes: string[],
): Promise<string[]> {
const written: string[] = []
// Load .scaffoldignore from the template base directory
const ignorePatterns = await loadIgnorePatterns(tpl.baseTemplatePath)
if (ignorePatterns.length > 0) {
log(config, LogLevel.debug, `Loaded .scaffoldignore patterns:`, ignorePatterns)
}
const allFiles = await getFileList(config, [tpl.template, ...excludes])
const files = filterIgnoredFiles(allFiles, ignorePatterns, tpl.baseTemplatePath)
for (const file of files) {
if (await isDir(file)) {
continue
}
log(config, LogLevel.debug, "Iterating files", { files, file })
const relPath = makeRelativePath(
path.dirname(removeGlob(file).replace(tpl.baseTemplatePath, "")),
)
const basePath = getBasePath(relPath)
log(config, LogLevel.debug, {
originalTemplate: tpl.origTemplate,
relativePath: relPath,
parsedTemplate: tpl.template,
inputFilePath: file,
baseTemplatePath: tpl.baseTemplatePath,
basePath,
isDirOrGlob: tpl.isDirOrGlob,
isGlob: tpl.isGlob,
})
const outputPath = await handleTemplateFile(config, { templatePath: file, basePath })
if (outputPath) {
written.push(outputPath)
}
}
return written
}
/** Executes the afterScaffold hook — either a function or a shell command string. */
async function runAfterScaffoldHook(config: ScaffoldConfig, files: string[]): Promise<void> {
const hook = config.afterScaffold!
if (typeof hook === "function") {
log(config, LogLevel.debug, "Running afterScaffold function hook")
await hook({ config, files })
return
}
// Shell command string
const outputDir = typeof config.output === "string" ? config.output : process.cwd()
const cwd = path.resolve(process.cwd(), outputDir)
log(config, LogLevel.info, `Running afterScaffold command: ${hook}`)
await new Promise<void>((resolve, reject) => {
const proc = exec(hook, { cwd })
proc.stdout?.on("data", (data: string) => {
log(config, LogLevel.info, data.toString().trimEnd())
})
proc.stderr?.on("data", (data: string) => {
log(config, LogLevel.warning, data.toString().trimEnd())
})
proc.on("close", (code) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`afterScaffold command exited with code ${code}`))
}
})
proc.on("error", reject)
})
}
/**
@@ -119,11 +197,8 @@ export async function Scaffold(config: ScaffoldConfig): Promise<void> {
* @return {Promise<void>} A promise that resolves when the scaffold is complete
*/
Scaffold.fromConfig = async function (
/** The path or URL to the config file */
pathOrUrl: string,
/** Information needed before loading the config */
config: MinimalConfig,
/** Any overrides to the loaded config */
overrides?: Resolver<ScaffoldCmdConfig, Partial<Omit<ScaffoldConfig, "name">>>,
): Promise<void> {
const tmpPath = path.resolve(os.tmpdir(), `scaffold-config-${Date.now()}`)

View File

@@ -166,10 +166,116 @@ export interface ScaffoldConfig {
outputPath: string,
): string | Buffer | undefined | Promise<string | Buffer | undefined>
/**
* Defines interactive inputs for the template. Each input becomes a template data variable.
*
* When running interactively, required inputs that are not already provided via `data` or CLI args
* will be prompted for. Optional inputs without a value will use their `default` if defined.
*
* @example
* ```typescript
* Scaffold({
* // ...
* inputs: {
* author: { message: "Author name", required: true },
* license: { message: "License", default: "MIT" },
* },
* })
* ```
*
* In templates: `{{ author }}`, `{{ license }}`
*
* @see {@link ScaffoldInput}
*/
inputs?: Record<string, ScaffoldInput>
/**
* A callback or shell command that runs after all files have been written.
*
* When provided as a **function** (Node.js API), it receives a context object with the scaffold
* config and the list of files that were written:
*
* ```typescript
* Scaffold({
* // ...
* afterScaffold: async ({ config, files }) => {
* console.log(`Created ${files.length} files`)
* execSync("npm install", { cwd: config.output })
* },
* })
* ```
*
* When provided as a **string** (CLI `--after` flag), it is executed as a shell command
* in the output directory after scaffolding completes.
*
* @see {@link AfterScaffoldContext}
*/
afterScaffold?: AfterScaffoldHook
/** @internal */
tmpDir?: string
}
/**
* Context passed to the {@link ScaffoldConfig.afterScaffold} hook.
*
* @category Config
*/
export interface AfterScaffoldContext {
/** The resolved scaffold config that was used. */
config: ScaffoldConfig
/** List of absolute paths to files that were written. */
files: string[]
}
/**
* A hook that runs after scaffolding completes.
* Can be a function receiving context, or a shell command string.
*
* @category Config
*/
export type AfterScaffoldHook = ((context: AfterScaffoldContext) => void | Promise<void>) | string
/**
* The type of an interactive input prompt.
*
* - `"text"` — free-form text input (default)
* - `"select"` — choose from a list of options
* - `"confirm"` — yes/no boolean prompt
* - `"number"` — numeric input
*
* @category Config
*/
export type ScaffoldInputType = "text" | "select" | "confirm" | "number"
/**
* Defines a single interactive input for a scaffold template.
*
* @example
* ```typescript
* inputs: {
* author: { message: "Author name", required: true },
* license: { type: "select", message: "License", options: ["MIT", "Apache-2.0", "GPL-3.0"] },
* private: { type: "confirm", message: "Private package?", default: false },
* port: { type: "number", message: "Dev server port", default: 3000 },
* }
* ```
*
* @category Config
*/
export interface ScaffoldInput {
/** The type of prompt. Defaults to `"text"`. */
type?: ScaffoldInputType
/** The prompt message shown to the user. Defaults to the input key name if omitted. */
message?: string
/** Whether this input must be provided. If true and missing, the user will be prompted interactively. */
required?: boolean
/** Default value. Type depends on the input type: string for text/select, boolean for confirm, number for number. */
default?: string | boolean | number
/** List of options for `type: "select"`. Each can be a string or `{ name, value }`. */
options?: (string | { name: string; value: string })[]
}
/**
* The names of the available helper functions that relate to text capitalization.
*
@@ -342,6 +448,8 @@ export type FileResponse<T> = T | FileResponseHandler<T>
* Contains less and more specific options than {@link ScaffoldConfig}.
*
* For more information about each option, see {@link ScaffoldConfig}.
*
* @internal
*/
export type ScaffoldCmdConfig = {
/** The name of the scaffold template to use. */
@@ -380,6 +488,8 @@ export type ScaffoldCmdConfig = {
version: boolean
/** Run a script before writing the files. This can be a command or a path to a file. The file contents will be passed to the given command. */
beforeWrite?: string
/** Run a shell command after all files have been written. Executed in the output directory. */
afterScaffold?: string
/** @internal */
tmpDir?: string
}
@@ -395,6 +505,7 @@ export type ScaffoldCmdConfig = {
*
* @see {@link ScaffoldConfig}
*
* @internal
* @category Config
*/
export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
@@ -406,6 +517,7 @@ export type ScaffoldConfigMap = Record<string, ScaffoldConfig>
* - A promise that resolves to a {@link ScaffoldConfigMap} object
* - A function that returns a promise that resolves to a {@link ScaffoldConfigMap} object
*
* @internal
* @category Config
*/
export type ScaffoldConfigFile = AsyncResolver<ScaffoldCmdConfig, ScaffoldConfigMap>
@@ -423,9 +535,11 @@ export type LogConfig = Pick<ScaffoldConfig, "logLevel">
export type ConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config">
/** @internal */
export type RemoteConfigLoadConfig = LogConfig & Pick<ScaffoldCmdConfig, "config" | "git" | "tmpDir">
export type RemoteConfigLoadConfig = LogConfig &
Pick<ScaffoldCmdConfig, "config" | "git" | "tmpDir">
/** @internal */
export type MinimalConfig = Pick<ScaffoldCmdConfig, "name" | "key">
/** @internal */
export type ListCommandCliOptions = Pick<ScaffoldCmdConfig, "config" | "git" | "logLevel" | "quiet">

View File

@@ -1,13 +1,19 @@
import { Resolver } from "./types"
// Re-export colors for backward compatibility
export { colorize, type TermColor } from "./colors"
/** Throws the error if non-null, no-ops otherwise. */
export function handleErr(err: NodeJS.ErrnoException | null): void {
if (err) throw err
}
/** Resolves a value that may be either a static value or a function that produces one. */
export function resolve<T, R = T>(resolver: Resolver<T, R>, arg: T): R {
return typeof resolver === "function" ? (resolver as (value: T) => R)(arg) : (resolver as R)
}
/** Wraps a static value in a resolver function. If already a function, returns as-is. */
export function wrapNoopResolver<T, R = T>(value: Resolver<T, R>): Resolver<T, R> {
if (typeof value === "function") {
return value
@@ -15,65 +21,3 @@ export function wrapNoopResolver<T, R = T>(value: Resolver<T, R>): Resolver<T, R
return (_) => value
}
const colorMap = {
reset: 0,
dim: 2,
bold: 1,
italic: 3,
underline: 4,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
gray: 90,
} as const
export type TermColor = keyof typeof colorMap
function _colorize(text: string, color: TermColor): string {
const c = colorMap[color]!
let r = 0
if (c > 1 && c < 30) {
r = c + 20
} else if (c === 1) {
r = 23
} else {
r = 0
}
return `\x1b[${c}m${text}\x1b[${r}m`
}
function isTemplateStringArray(template: TemplateStringsArray | unknown): template is TemplateStringsArray {
return Array.isArray(template) && typeof template[0] === "string"
}
const createColorize =
(color: TermColor) =>
(template: TemplateStringsArray | unknown, ...params: unknown[]): string => {
return isTemplateStringArray(template)
? _colorize(
(template as TemplateStringsArray).reduce((acc, str, i) => acc + str + (params[i] ?? ""), ""),
color,
)
: _colorize(String(template), color)
}
type TemplateStringsFn = ReturnType<typeof createColorize> & ((text: string) => string)
type TemplateStringsFns = { [key in TermColor]: TemplateStringsFn }
export const colorize: typeof _colorize & TemplateStringsFns = Object.assign(
_colorize,
Object.entries(colorMap).reduce(
(acc, [key]) => {
acc[key as TermColor] = createColorize(key as TermColor)
return acc
},
{} as Record<TermColor, TemplateStringsFn>,
),
)

171
src/validate.ts Normal file
View File

@@ -0,0 +1,171 @@
import { z } from "zod/v4"
import { pathExists } from "./fs-utils"
// --- Reusable schemas ---
/** Schema for a JavaScript function value. */
const functionSchema = z
.any()
.refine((v) => typeof v === "function", { message: "Expected a function" })
/** Schema for a value that can be either a string or a function. */
const stringOrFunctionSchema = z.union([z.string(), functionSchema])
/** Schema for a value that can be either a boolean or a function. */
const booleanOrFunctionSchema = z.union([z.boolean(), functionSchema])
/** Schema for a select input option — either a plain string or a `{ name, value }` object. */
const selectOptionSchema = z.union([z.string(), z.object({ name: z.string(), value: z.string() })])
/** Schema for the input type enum. */
const inputTypeSchema = z.enum(["text", "select", "confirm", "number"])
/** Schema for the log level enum. */
const logLevelSchema = z.enum(["none", "debug", "info", "warning", "error"])
// --- Input schema ---
/** Zod schema for a single scaffold input definition. */
const scaffoldInputSchema = z.object({
type: inputTypeSchema.optional(),
message: z.string().optional(),
required: z.boolean().optional(),
default: z.union([z.string(), z.boolean(), z.number()]).optional(),
options: z.array(selectOptionSchema).optional(),
})
type InputDef = z.infer<typeof scaffoldInputSchema>
function validateInputSemantics(
key: string,
input: InputDef,
): { path: (string | number)[]; message: string }[] {
const issues: { path: (string | number)[]; message: string }[] = []
if (input.type === "select" && (!input.options || input.options.length === 0)) {
issues.push({
path: ["inputs", key, "options"],
message: "select input must have a non-empty options array",
})
}
if (
input.type === "confirm" &&
input.default !== undefined &&
typeof input.default !== "boolean"
) {
issues.push({
path: ["inputs", key, "default"],
message: "confirm input default must be a boolean",
})
}
if (input.type === "number" && input.default !== undefined && typeof input.default !== "number") {
issues.push({
path: ["inputs", key, "default"],
message: "number input default must be a number",
})
}
return issues
}
// --- Config schema ---
/** Zod schema for ScaffoldConfig. */
const scaffoldConfigSchema = z
.object({
name: z.string().min(1, "name is required"),
templates: z.array(z.string()).min(1, "templates must contain at least one entry"),
output: stringOrFunctionSchema,
subdir: z.boolean().optional(),
data: z.record(z.string(), z.unknown()).optional(),
overwrite: booleanOrFunctionSchema.optional(),
logLevel: logLevelSchema.optional(),
dryRun: z.boolean().optional(),
helpers: z.record(z.string(), functionSchema).optional(),
subdirHelper: z.string().optional(),
inputs: z.record(z.string(), scaffoldInputSchema).optional(),
beforeWrite: functionSchema.optional(),
afterScaffold: stringOrFunctionSchema.optional(),
tmpDir: z.string().optional(),
})
.check((ctx) => {
const config = ctx.value
if (config.subdirHelper && !config.subdir) {
ctx.issues.push({
code: "custom",
message: "subdirHelper is set but subdir is not enabled",
path: ["subdirHelper"],
input: config,
})
}
if (config.inputs) {
for (const [key, val] of Object.entries(config.inputs)) {
for (const issue of validateInputSemantics(key, val)) {
ctx.issues.push({ code: "custom", ...issue, input: config })
}
}
}
})
export {
scaffoldConfigSchema,
scaffoldInputSchema,
functionSchema,
stringOrFunctionSchema,
booleanOrFunctionSchema,
selectOptionSchema,
inputTypeSchema,
logLevelSchema,
}
/**
* Validates a scaffold config and returns a list of human-readable errors.
* Returns an empty array if the config is valid.
*/
export function validateConfig(config: unknown): string[] {
const result = scaffoldConfigSchema.safeParse(config)
if (result.success) {
return []
}
return result.error.issues.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"
return `${path}: ${issue.message}`
})
}
/**
* Validates template paths exist on disk.
* Only checks non-glob, non-negation paths.
*/
export async function validateTemplatePaths(templates: string[]): Promise<string[]> {
const errors: string[] = []
for (const tpl of templates) {
if (tpl.startsWith("!") || tpl.includes("*")) continue
if (!(await pathExists(tpl))) {
errors.push(`templates: path does not exist: ${tpl}`)
}
}
return errors
}
/**
* Validates the config and throws a formatted error if any issues are found.
* Checks both schema validity and template path existence.
*/
export async function assertConfigValid(config: unknown): Promise<void> {
const schemaErrors = validateConfig(config)
const pathErrors =
config &&
typeof config === "object" &&
"templates" in config &&
Array.isArray((config as { templates: unknown }).templates)
? await validateTemplatePaths((config as { templates: string[] }).templates)
: []
const allErrors = [...schemaErrors, ...pathErrors]
if (allErrors.length > 0) {
const lines = allErrors.map((e) => ` - ${e}`)
throw new Error(`Invalid scaffold config:\n${lines.join("\n")}`)
}
}

View File

@@ -1,17 +1,19 @@
import { describe, test, expect, beforeEach, afterEach, beforeAll, vi } from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import { Console } from "console"
import { LogLevel, ScaffoldCmdConfig } from "../src/types"
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
import * as config from "../src/config"
import { resolve } from "../src/utils"
import configFile from "./test-config"
import { findConfigFile } from "../src/config"
import { findConfigFile, getOptionValueForFile } from "../src/config"
import { registerHelpers } from "../src/parser"
import path from "path"
jest.mock("../src/git", () => {
vi.mock("../src/git", async () => {
const actual = await vi.importActual<typeof import("../src/git")>("../src/git")
return {
__esModule: true,
...jest.requireActual("../src/git"),
...actual,
getGitConfig: () => {
return Promise.resolve(blankCliConf)
},
@@ -53,17 +55,69 @@ describe("config", () => {
})
test("works with quotes", () => {
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({ key: "value test", name: "test" })
expect(parseAppendData('key="value test"', blankCliConf)).toEqual({
key: "value test",
name: "test",
})
})
test("handles JSON array values with :=", () => {
expect(parseAppendData('items:=["a","b"]', blankCliConf)).toEqual({
items: ["a", "b"],
name: "test",
})
})
test("handles JSON boolean with :=", () => {
expect(parseAppendData("flag:=true", blankCliConf)).toEqual({ flag: true, name: "test" })
expect(parseAppendData("flag:=false", blankCliConf)).toEqual({ flag: false, name: "test" })
})
test("handles JSON null with :=", () => {
expect(parseAppendData("val:=null", blankCliConf)).toEqual({ val: null, name: "test" })
})
test("handles JSON object with :=", () => {
expect(parseAppendData('obj:={"a":1}', blankCliConf)).toEqual({ obj: { a: 1 }, name: "test" })
})
test("handles single quoted values", () => {
expect(parseAppendData("key='value test'", blankCliConf)).toEqual({
key: "value test",
name: "test",
})
})
test("handles empty string value", () => {
expect(parseAppendData("key=", blankCliConf)).toEqual({ key: "", name: "test" })
})
test("handles negative number with :=", () => {
expect(parseAppendData("num:=-42", blankCliConf)).toEqual({ num: -42, name: "test" })
})
test("handles float with :=", () => {
expect(parseAppendData("num:=3.14", blankCliConf)).toEqual({ num: 3.14, name: "test" })
})
})
describe("githubPartToUrl", () => {
test("works", () => {
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual("https://github.com/chenasraf/simple-scaffold.git")
expect(githubPartToUrl("chenasraf/simple-scaffold")).toEqual(
"https://github.com/chenasraf/simple-scaffold.git",
)
expect(githubPartToUrl("chenasraf/simple-scaffold.git")).toEqual(
"https://github.com/chenasraf/simple-scaffold.git",
)
})
test("handles organization repos", () => {
expect(githubPartToUrl("org/sub-repo")).toEqual("https://github.com/org/sub-repo.git")
})
test("handles repos with dots in name", () => {
expect(githubPartToUrl("user/my.repo")).toEqual("https://github.com/user/my.repo.git")
})
})
describe("parseConfigFile", () => {
@@ -116,6 +170,111 @@ describe("config", () => {
expect(result.output).toEqual("examples/test-output/override")
})
})
test("throws when name is missing", async () => {
await expect(
parseConfigFile({
...blankCliConf,
name: "",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow("Missing required option: name")
})
test("preserves dryRun setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
dryRun: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.dryRun).toBe(true)
})
test("preserves subdir setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
subdir: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.subdir).toBe(true)
})
test("preserves overwrite setting", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
overwrite: true,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.overwrite).toBe(true)
})
test("merges data from config and appendData", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
data: { key1: "val1" },
appendData: { key2: "val2" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.data).toEqual({ key1: "val1", key2: "val2" })
})
test("appendData overrides data", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
data: { key: "original" },
appendData: { key: "overridden" },
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.data?.key).toEqual("overridden")
})
test("sets subdirHelper from config", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
subdirHelper: "pascalCase",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.subdirHelper).toEqual("pascalCase")
})
test("handles empty templates array", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "test",
templates: [],
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.templates).toEqual([])
})
test("throws when config key not found", async () => {
await expect(
parseConfigFile({
...blankCliConf,
name: "test",
config: path.resolve(__dirname, "test-config.js"),
key: "nonexistent",
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow('Template "nonexistent" not found')
})
test("uses default key when key not specified", async () => {
const result = await parseConfigFile({
...blankCliConf,
name: "MyComponent",
templates: undefined as unknown as string[],
config: path.resolve(__dirname, "test-config.js"),
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
})
expect(result.templates.length).toBeGreaterThan(0)
})
})
describe("getConfig", () => {
@@ -139,6 +298,60 @@ describe("config", () => {
})
})
describe("getRemoteConfig", () => {
test("throws for unsupported protocol", async () => {
await expect(
config.getRemoteConfig({
git: "ftp://example.com/repo.git",
logLevel: LogLevel.none,
tmpDir: `/tmp/scaffold-config-${Date.now()}`,
}),
).rejects.toThrow("Unsupported protocol")
})
})
describe("getOptionValueForFile", () => {
const conf: ScaffoldConfig = {
name: "test",
output: "output",
templates: [],
logLevel: LogLevel.none,
data: { name: "test" },
}
beforeAll(() => {
registerHelpers(conf)
})
test("returns static string value", () => {
expect(getOptionValueForFile(conf, "/some/path", "static-value")).toEqual("static-value")
})
test("returns static boolean value", () => {
expect(getOptionValueForFile(conf, "/some/path", true)).toBe(true)
expect(getOptionValueForFile(conf, "/some/path", false)).toBe(false)
})
test("calls function with file path info", () => {
const fn = vi.fn().mockReturnValue("custom-output")
const result = getOptionValueForFile(conf, "/home/user/file.txt", fn)
expect(result).toEqual("custom-output")
expect(fn).toHaveBeenCalledWith("/home/user/file.txt", expect.any(String), expect.any(String))
})
test("returns default value when fn is not a function and no value", () => {
expect(
getOptionValueForFile(conf, "/some/path", undefined as unknown as string, "default"),
).toEqual("default")
})
test("function receives parsed basename", () => {
const fn = (_fullPath: string, _basedir: string, basename: string) => basename
const result = getOptionValueForFile(conf, "/home/user/{{name}}.txt", fn)
expect(result).toEqual("test.txt")
})
})
describe("findConfigFile", () => {
const struct1 = {
"scaffold.config.js": `module.exports = '${JSON.stringify(blankConfig)}'`,
@@ -153,7 +366,7 @@ describe("config", () => {
"scaffold.json": JSON.stringify(blankConfig),
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
// console.log("Mocking:", fileStruct)
@@ -175,12 +388,152 @@ describe("config", () => {
for (const struct of [struct1, struct2, struct3, struct4]) {
const [k] = Object.keys(struct)
describe(`finds config file ${k}`, () => {
withMock(struct, async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(k)
})
})
describe(
`finds config file ${k}`,
withMock(struct, () => {
test(`finds ${k}`, async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(k)
})
}),
)
}
describe(
"finds .mjs config file",
withMock({ "scaffold.config.mjs": "export default {}" }, () => {
test("finds scaffold.config.mjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.mjs")
})
}),
)
describe(
"priority order",
withMock(
{
"scaffold.config.js": "module.exports = {}",
"scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.config.js over scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.js")
})
},
),
)
describe(
"throws when no config found",
withMock({ "unrelated-file.txt": "content" }, () => {
test("throws error when no config file exists", async () => {
await expect(findConfigFile(process.cwd())).rejects.toThrow("Could not find config file")
})
}),
)
describe(
"finds scaffold.config.cjs",
withMock({ "scaffold.config.cjs": "module.exports = {}" }, () => {
test("finds .cjs config file", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.cjs")
})
}),
)
describe(
"finds scaffold.config.json",
withMock({ "scaffold.config.json": "{}" }, () => {
test("finds .json config file", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.json")
})
}),
)
describe(
"finds scaffold.mjs",
withMock({ "scaffold.mjs": "export default {}" }, () => {
test("finds scaffold.mjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.mjs")
})
}),
)
describe(
"finds scaffold.cjs",
withMock({ "scaffold.cjs": "module.exports = {}" }, () => {
test("finds scaffold.cjs", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.cjs")
})
}),
)
describe(
"finds scaffold.json",
withMock({ "scaffold.json": "{}" }, () => {
test("finds scaffold.json", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.json")
})
}),
)
describe(
"finds .scaffold.js",
withMock({ ".scaffold.js": "module.exports = {}" }, () => {
test("finds dotfile config", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(".scaffold.js")
})
}),
)
describe(
"finds .scaffold.json",
withMock({ ".scaffold.json": "{}" }, () => {
test("finds dotfile json config", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual(".scaffold.json")
})
}),
)
describe(
"prefers scaffold.config over .scaffold",
withMock(
{
"scaffold.config.js": "module.exports = {}",
".scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.config.js over .scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.config.js")
})
},
),
)
describe(
"prefers scaffold over .scaffold",
withMock(
{
"scaffold.js": "module.exports = {}",
".scaffold.js": "module.exports = {}",
},
() => {
test("prefers scaffold.js over .scaffold.js", async () => {
const result = await findConfigFile(process.cwd())
expect(result).toEqual("scaffold.js")
})
},
),
)
})
})

456
tests/file.test.ts Normal file
View File

@@ -0,0 +1,456 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import { Console } from "console"
import path from "node:path"
import os from "node:os"
import {
removeGlob,
makeRelativePath,
getBasePath,
getOutputDir,
getUniqueTmpPath,
pathExists,
createDirIfNotExists,
getTemplateGlobInfo,
getTemplateFileInfo,
copyFileTransformed,
handleTemplateFile,
getFileList,
} from "../src/file"
import { ScaffoldConfig, LogLevel } from "../src/types"
import { registerHelpers } from "../src/parser"
import { readFileSync } from "fs"
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs(fileStruct)
})
testFn()
afterEach(() => {
mockFs.restore()
})
}
}
const baseConfig: ScaffoldConfig = {
name: "test_app",
output: "output",
templates: ["input"],
logLevel: LogLevel.none,
data: { name: "test_app" },
tmpDir: ".",
}
describe("file utilities", () => {
describe("removeGlob", () => {
test("removes single wildcard", () => {
expect(removeGlob("input/*")).toEqual(path.normalize("input/"))
})
test("removes double wildcard", () => {
expect(removeGlob("input/**/*")).toEqual(path.normalize("input///"))
})
test("returns path unchanged when no glob", () => {
expect(removeGlob("input/file.txt")).toEqual(path.normalize("input/file.txt"))
})
test("removes wildcards from nested path", () => {
expect(removeGlob("a/b/*/c/**")).toEqual(path.normalize("a/b//c//"))
})
test("handles empty string", () => {
expect(removeGlob("")).toEqual(".")
})
})
describe("makeRelativePath", () => {
test("removes leading separator", () => {
expect(makeRelativePath(path.sep + "some/path")).toEqual("some/path")
})
test("returns path unchanged if no leading separator", () => {
expect(makeRelativePath("some/path")).toEqual("some/path")
})
test("handles empty string", () => {
expect(makeRelativePath("")).toEqual("")
})
test("removes only the first separator", () => {
expect(makeRelativePath(path.sep + "a" + path.sep + "b")).toEqual("a" + path.sep + "b")
})
})
describe("getBasePath", () => {
test("resolves relative path against cwd", () => {
const result = getBasePath("some/path")
expect(result).toEqual("some/path")
})
test("handles empty string", () => {
const result = getBasePath("")
expect(result).toEqual("")
})
test("handles current directory", () => {
const result = getBasePath(".")
expect(result).toEqual("")
})
})
describe("getOutputDir", () => {
test("returns output path without subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output"))
})
test("returns output path with subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output", "test_app"))
})
test("applies subdirHelper to subdir name", () => {
const config: ScaffoldConfig = {
...baseConfig,
subdir: true,
subdirHelper: "pascalCase",
}
registerHelpers(config)
const result = getOutputDir(config, "output", "")
expect(result).toEqual(path.resolve(process.cwd(), "output", "TestApp"))
})
test("includes basePath in output", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: false }
registerHelpers(config)
const result = getOutputDir(config, "output", "nested/dir")
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested/dir"))
})
test("combines output, basePath, and subdir", () => {
const config: ScaffoldConfig = { ...baseConfig, subdir: true }
registerHelpers(config)
const result = getOutputDir(config, "output", "nested")
expect(result).toEqual(path.resolve(process.cwd(), "output", "nested", "test_app"))
})
})
describe("getUniqueTmpPath", () => {
test("returns a path in os temp directory", () => {
const result = getUniqueTmpPath()
expect(result.startsWith(os.tmpdir())).toBe(true)
})
test("includes scaffold-config prefix", () => {
const result = getUniqueTmpPath()
expect(path.basename(result)).toMatch(/^scaffold-config-/)
})
test("generates unique paths", () => {
const a = getUniqueTmpPath()
const b = getUniqueTmpPath()
expect(a).not.toEqual(b)
})
})
describe(
"pathExists",
withMock(
{
"existing-file.txt": "content",
"existing-dir": {},
},
() => {
test("returns true for existing file", async () => {
expect(await pathExists("existing-file.txt")).toBe(true)
})
test("returns true for existing directory", async () => {
expect(await pathExists("existing-dir")).toBe(true)
})
test("returns false for non-existing path", async () => {
expect(await pathExists("non-existing")).toBe(false)
})
},
),
)
describe(
"createDirIfNotExists",
withMock({}, () => {
test("creates directory", async () => {
await createDirIfNotExists("new-dir", { logLevel: LogLevel.none, dryRun: false })
expect(await pathExists("new-dir")).toBe(true)
})
test("creates nested directories recursively", async () => {
await createDirIfNotExists("a/b/c", { logLevel: LogLevel.none, dryRun: false })
expect(await pathExists("a/b/c")).toBe(true)
})
test("does not create directory in dry run mode", async () => {
await createDirIfNotExists("dry-dir", { logLevel: LogLevel.none, dryRun: true })
expect(await pathExists("dry-dir")).toBe(false)
})
test("does not throw if directory already exists", async () => {
await createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false })
await expect(
createDirIfNotExists("existing", { logLevel: LogLevel.none, dryRun: false }),
).resolves.toBeUndefined()
})
}),
)
describe(
"getTemplateGlobInfo",
withMock(
{
"template-dir": {
"file1.txt": "content1",
"file2.txt": "content2",
},
"single-file.txt": "content",
},
() => {
test("detects directory template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
expect(result.isDirOrGlob).toBe(true)
expect(result.isGlob).toBe(false)
expect(result.template).toEqual(path.join("template-dir", "**", "*"))
})
test("detects glob template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir/**/*.txt")
expect(result.isDirOrGlob).toBe(true)
expect(result.isGlob).toBe(true)
})
test("preserves non-glob single file", async () => {
const result = await getTemplateGlobInfo(baseConfig, "single-file.txt")
expect(result.isDirOrGlob).toBe(false)
expect(result.isGlob).toBe(false)
expect(result.template).toEqual("single-file.txt")
})
test("stores original template", async () => {
const result = await getTemplateGlobInfo(baseConfig, "template-dir")
expect(result.origTemplate).toEqual("template-dir")
})
},
),
)
describe(
"getFileList",
withMock(
{
templates: {
"file1.txt": "content1",
"file2.js": "content2",
".hidden": "hidden content",
nested: {
"file3.txt": "content3",
},
},
},
() => {
test("lists all files with glob", async () => {
const files = await getFileList(baseConfig, ["templates/**/*"])
expect(files.length).toBe(4)
})
test("includes dotfiles", async () => {
const files = await getFileList(baseConfig, ["templates/**/*"])
expect(files.some((f) => f.includes(".hidden"))).toBe(true)
})
test("filters by extension", async () => {
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
expect(files.length).toBe(2)
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
})
test("supports exclusion patterns", async () => {
const files = await getFileList(baseConfig, ["templates/**/*.txt"])
expect(files.some((f) => f.includes(".hidden"))).toBe(false)
expect(files.every((f) => f.endsWith(".txt"))).toBe(true)
})
},
),
)
describe(
"getTemplateFileInfo",
withMock(
{
input: {
"{{name}}.txt": "Hello {{name}}",
},
output: {},
},
() => {
test("calculates correct output path", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
const info = await getTemplateFileInfo(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
expect(info.inputPath).toEqual(path.resolve(process.cwd(), "input/{{name}}.txt"))
expect(info.outputPath).toContain("test_app.txt")
expect(info.exists).toBe(false)
})
test("detects existing output file", async () => {
mockFs.restore()
mockFs({
input: { "{{name}}.txt": "Hello {{name}}" },
output: { "test_app.txt": "existing" },
})
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
const info = await getTemplateFileInfo(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
expect(info.exists).toBe(true)
})
},
),
)
describe(
"copyFileTransformed",
withMock(
{
"input.txt": "Hello {{name}}",
output: {},
},
() => {
test("writes transformed content", async () => {
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("Hello test_app")
})
test("does not write in dry run mode", async () => {
const config: ScaffoldConfig = { ...baseConfig, dryRun: true }
registerHelpers(config)
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
expect(await pathExists("output/result.txt")).toBe(false)
})
test("skips existing file without overwrite", async () => {
mockFs.restore()
mockFs({
"input.txt": "Hello {{name}}",
output: { "result.txt": "original" },
})
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await copyFileTransformed(config, {
exists: true,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("original")
})
test("overwrites existing file with overwrite flag", async () => {
mockFs.restore()
mockFs({
"input.txt": "Hello {{name}}",
output: { "result.txt": "original" },
})
const config: ScaffoldConfig = { ...baseConfig }
registerHelpers(config)
await copyFileTransformed(config, {
exists: true,
overwrite: true,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("Hello test_app")
})
test("calls beforeWrite callback", async () => {
const config: ScaffoldConfig = {
...baseConfig,
beforeWrite: (content) => content.toString().toUpperCase(),
}
registerHelpers(config)
await createDirIfNotExists("output", { logLevel: LogLevel.none, dryRun: false })
await copyFileTransformed(config, {
exists: false,
overwrite: false,
outputPath: "output/result.txt",
inputPath: "input.txt",
})
const content = readFileSync("output/result.txt").toString()
expect(content).toEqual("HELLO TEST_APP")
})
},
),
)
describe(
"handleTemplateFile",
withMock(
{
input: {
"{{name}}.txt": "Content for {{name}}",
},
output: {},
},
() => {
test("processes template file end to end", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
await handleTemplateFile(config, {
templatePath: "input/{{name}}.txt",
basePath: "",
})
const content = readFileSync(path.join("output", "test_app.txt")).toString()
expect(content).toEqual("Content for test_app")
})
test("throws for non-existing template", async () => {
const config: ScaffoldConfig = { ...baseConfig, tmpDir: "." }
registerHelpers(config)
await expect(
handleTemplateFile(config, {
templatePath: "non-existing.txt",
basePath: "",
}),
).rejects.toThrow()
})
},
),
)
})

122
tests/ignore.test.ts Normal file
View File

@@ -0,0 +1,122 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import path from "node:path"
import { parseIgnoreFile, loadIgnorePatterns, filterIgnoredFiles } from "../src/ignore"
describe("ignore", () => {
describe("parseIgnoreFile", () => {
test("parses patterns", () => {
const result = parseIgnoreFile("node_modules\n*.log\n")
expect(result).toEqual(["node_modules", "*.log"])
})
test("skips comments", () => {
const result = parseIgnoreFile("# comment\nfoo\n# another\nbar")
expect(result).toEqual(["foo", "bar"])
})
test("skips empty lines", () => {
const result = parseIgnoreFile("foo\n\n\nbar\n")
expect(result).toEqual(["foo", "bar"])
})
test("trims whitespace", () => {
const result = parseIgnoreFile(" foo \n bar ")
expect(result).toEqual(["foo", "bar"])
})
test("handles empty file", () => {
expect(parseIgnoreFile("")).toEqual([])
})
test("handles comments-only file", () => {
expect(parseIgnoreFile("# just comments\n# nothing here")).toEqual([])
})
})
describe("filterIgnoredFiles", () => {
test("filters files matching patterns", () => {
const files = ["templates/file.txt", "templates/debug.log", "templates/other.js"]
const result = filterIgnoredFiles(files, ["*.log"], "templates")
expect(result).toEqual(["templates/file.txt", "templates/other.js"])
})
test("always excludes .scaffoldignore", () => {
const files = ["templates/file.txt", "templates/.scaffoldignore"]
const result = filterIgnoredFiles(files, [], "templates")
expect(result).toEqual(["templates/file.txt"])
})
test("matches by relative path", () => {
const files = ["templates/src/index.ts", "templates/dist/index.js", "templates/src/utils.ts"]
const result = filterIgnoredFiles(files, ["dist/**"], "templates")
expect(result).toEqual(["templates/src/index.ts", "templates/src/utils.ts"])
})
test("matches by basename", () => {
const files = ["templates/README.md", "templates/nested/README.md", "templates/file.txt"]
const result = filterIgnoredFiles(files, ["README.md"], "templates")
expect(result).toEqual(["templates/file.txt"])
})
test("handles multiple patterns", () => {
const files = ["tpl/a.txt", "tpl/b.log", "tpl/c.tmp", "tpl/d.ts"]
const result = filterIgnoredFiles(files, ["*.log", "*.tmp"], "tpl")
expect(result).toEqual(["tpl/a.txt", "tpl/d.ts"])
})
test("returns all files when no patterns", () => {
const files = ["tpl/a.txt", "tpl/b.txt"]
const result = filterIgnoredFiles(files, [], "tpl")
expect(result).toEqual(files)
})
test("handles glob patterns with directories", () => {
const files = [
path.join("tpl", "src", "index.ts"),
path.join("tpl", "node_modules", "pkg", "index.js"),
path.join("tpl", "file.txt"),
]
const result = filterIgnoredFiles(files, ["node_modules/**"], "tpl")
expect(result).toEqual([path.join("tpl", "src", "index.ts"), path.join("tpl", "file.txt")])
})
})
describe("loadIgnorePatterns", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
})
afterEach(() => {
mockFs.restore()
})
test("returns empty array when no .scaffoldignore exists", async () => {
mockFs({ templates: { "file.txt": "content" } })
const result = await loadIgnorePatterns("templates")
expect(result).toEqual([])
})
test("reads and parses .scaffoldignore", async () => {
mockFs({
templates: {
".scaffoldignore": "*.log\nnode_modules\n",
"file.txt": "content",
},
})
const result = await loadIgnorePatterns("templates")
expect(result).toEqual(["*.log", "node_modules"])
})
test("ignores comments in .scaffoldignore", async () => {
mockFs({
templates: {
".scaffoldignore": "# This is a comment\n*.tmp\n",
},
})
const result = await loadIgnorePatterns("templates")
expect(result).toEqual(["*.tmp"])
})
})
})

115
tests/init.test.ts Normal file
View File

@@ -0,0 +1,115 @@
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import { readFileSync, existsSync } from "fs"
import path from "path"
vi.mock("@inquirer/input", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/select", () => ({
default: vi.fn(),
}))
import selectMock from "@inquirer/select"
import { initScaffold } from "../src/init"
describe("init", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
vi.clearAllMocks()
mockFs({})
})
afterEach(() => {
mockFs.restore()
})
test("creates js config and example template", async () => {
await initScaffold({ format: "js", dir: process.cwd() })
expect(existsSync("scaffold.config.js")).toBe(true)
const config = readFileSync("scaffold.config.js", "utf-8")
expect(config).toContain("module.exports")
expect(config).toContain("templates/default")
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(true)
const template = readFileSync(path.join("templates", "default", "{{name}}.md"), "utf-8")
expect(template).toContain("{{ name }}")
})
test("creates mjs config", async () => {
await initScaffold({ format: "mjs", dir: process.cwd() })
expect(existsSync("scaffold.config.mjs")).toBe(true)
const config = readFileSync("scaffold.config.mjs", "utf-8")
expect(config).toContain("export default")
})
test("creates json config", async () => {
await initScaffold({ format: "json", dir: process.cwd() })
expect(existsSync("scaffold.config.json")).toBe(true)
const config = readFileSync("scaffold.config.json", "utf-8")
const parsed = JSON.parse(config)
expect(parsed.default).toBeDefined()
expect(parsed.default.templates).toEqual(["templates/default"])
})
test("does not overwrite existing config", async () => {
mockFs.restore()
mockFs({
"scaffold.config.js": "// existing config",
})
await initScaffold({ format: "js", dir: process.cwd() })
const config = readFileSync("scaffold.config.js", "utf-8")
expect(config).toBe("// existing config")
})
test("does not overwrite existing template dir", async () => {
mockFs.restore()
mockFs({
templates: {
default: {
"existing.md": "# Existing",
},
},
})
await initScaffold({ format: "js", dir: process.cwd() })
expect(existsSync(path.join("templates", "default", "existing.md"))).toBe(true)
expect(existsSync(path.join("templates", "default", "{{name}}.md"))).toBe(false)
})
test("skips example template when createExample is false", async () => {
await initScaffold({ format: "js", dir: process.cwd(), createExample: false })
expect(existsSync("scaffold.config.js")).toBe(true)
expect(existsSync("templates")).toBe(false)
})
test("prompts for format when not provided", async () => {
vi.mocked(selectMock).mockResolvedValue("js")
await initScaffold({ dir: process.cwd() })
expect(selectMock).toHaveBeenCalledOnce()
expect(existsSync("scaffold.config.js")).toBe(true)
})
test("creates config in custom directory", async () => {
mockFs.restore()
mockFs({
"my-project": {},
})
await initScaffold({ format: "js", dir: path.resolve("my-project") })
expect(existsSync(path.join("my-project", "scaffold.config.js"))).toBe(true)
expect(existsSync(path.join("my-project", "templates", "default", "{{name}}.md"))).toBe(true)
})
})

175
tests/logger.test.ts Normal file
View File

@@ -0,0 +1,175 @@
import { describe, test, expect, beforeEach, afterEach, vi, type MockInstance } from "vitest"
import { log, logInitStep, logInputFile } from "../src/logger"
import { LogLevel, ScaffoldConfig } from "../src/types"
describe("logger", () => {
let consoleSpy: {
log: MockInstance
warn: MockInstance
error: MockInstance
}
beforeEach(() => {
consoleSpy = {
log: vi.spyOn(console, "log").mockImplementation(() => void 0),
warn: vi.spyOn(console, "warn").mockImplementation(() => void 0),
error: vi.spyOn(console, "error").mockImplementation(() => void 0),
}
})
afterEach(() => {
consoleSpy.log.mockRestore()
consoleSpy.warn.mockRestore()
consoleSpy.error.mockRestore()
})
describe("log", () => {
test("does not log when logLevel is none", () => {
log({ logLevel: LogLevel.none }, LogLevel.info, "test")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("logs info messages with console.log", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, "test message")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("logs warning messages with console.warn", () => {
log({ logLevel: LogLevel.warning }, LogLevel.warning, "warning message")
expect(consoleSpy.warn).toHaveBeenCalled()
})
test("logs error messages with console.error", () => {
log({ logLevel: LogLevel.error }, LogLevel.error, "error message")
expect(consoleSpy.error).toHaveBeenCalled()
})
test("filters out messages below configured level", () => {
log({ logLevel: LogLevel.warning }, LogLevel.info, "should be filtered")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("filters out debug messages when level is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.debug, "debug message")
expect(consoleSpy.log).not.toHaveBeenCalled()
})
test("shows debug messages when level is debug", () => {
log({ logLevel: LogLevel.debug }, LogLevel.debug, "debug message")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("shows all levels when configured as debug", () => {
log({ logLevel: LogLevel.debug }, LogLevel.debug, "d")
log({ logLevel: LogLevel.debug }, LogLevel.info, "i")
log({ logLevel: LogLevel.debug }, LogLevel.warning, "w")
log({ logLevel: LogLevel.debug }, LogLevel.error, "e")
expect(consoleSpy.log).toHaveBeenCalledTimes(2) // debug + info
expect(consoleSpy.warn).toHaveBeenCalledTimes(1)
expect(consoleSpy.error).toHaveBeenCalledTimes(1)
})
test("handles Error objects", () => {
log({ logLevel: LogLevel.error }, LogLevel.error, new Error("test error"))
expect(consoleSpy.error).toHaveBeenCalled()
})
test("handles objects with util.inspect", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, { key: "value" })
expect(consoleSpy.log).toHaveBeenCalled()
})
test("handles multiple arguments", () => {
log({ logLevel: LogLevel.info }, LogLevel.info, "a", "b", "c")
expect(consoleSpy.log).toHaveBeenCalled()
// First call, should have 3 arguments
expect(consoleSpy.log.mock.calls[0].length).toBe(3)
})
test("defaults to info when logLevel is undefined", () => {
log({ logLevel: undefined as unknown as LogLevel }, LogLevel.info, "test")
expect(consoleSpy.log).toHaveBeenCalled()
})
test("error level shows when logLevel is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.error, "error")
expect(consoleSpy.error).toHaveBeenCalled()
})
test("warning level shows when logLevel is info", () => {
log({ logLevel: LogLevel.info }, LogLevel.warning, "warning")
expect(consoleSpy.warn).toHaveBeenCalled()
})
})
describe("logInitStep", () => {
test("logs config at debug level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.debug,
data: { name: "test" },
}
logInitStep(config)
expect(consoleSpy.log).toHaveBeenCalled()
})
test("does not log at info level (debug only)", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.info,
data: { name: "test" },
}
logInitStep(config)
// Full config is debug-only, nothing logged at info
expect(consoleSpy.log).not.toHaveBeenCalled()
})
})
describe("logInputFile", () => {
test("logs file info at debug level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.debug,
data: { name: "test" },
}
logInputFile(config, {
originalTemplate: "input",
relativePath: ".",
parsedTemplate: "input/**/*",
inputFilePath: "input/file.txt",
nonGlobTemplate: "input",
basePath: "",
isDirOrGlob: true,
isGlob: false,
})
expect(consoleSpy.log).toHaveBeenCalled()
})
test("does not log at info level", () => {
const config: ScaffoldConfig = {
name: "test",
output: "output",
templates: ["input"],
logLevel: LogLevel.info,
data: { name: "test" },
}
logInputFile(config, {
originalTemplate: "input",
relativePath: ".",
parsedTemplate: "input/**/*",
inputFilePath: "input/file.txt",
nonGlobTemplate: "input",
basePath: "",
isDirOrGlob: true,
isGlob: false,
})
expect(consoleSpy.log).not.toHaveBeenCalled()
})
})
})

View File

@@ -1,7 +1,8 @@
import { describe, test, expect, beforeAll, afterAll } from "vitest"
import { ScaffoldConfig } from "../src/types"
import path from "node:path"
import * as dateFns from "date-fns"
import { dateHelper, defaultHelpers, handlebarsParse, nowHelper } from "../src/parser"
import { dateHelper, defaultHelpers, handlebarsParse, nowHelper, registerHelpers } from "../src/parser"
const blankConf: ScaffoldConfig = {
logLevel: "none",
@@ -61,6 +62,88 @@ describe("parser", () => {
),
).toEqual(Buffer.from("/home/test/test {{escaped}}.txt"))
})
test("should replace name token in content", () => {
const result = handlebarsParse(blankConf, "Hello {{name}}")
expect(result.toString()).toEqual("Hello test")
})
test("should replace multiple tokens", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "app", version: "1.0" },
}
expect(handlebarsParse(config, "{{name}} v{{version}}").toString()).toEqual("app v1.0")
})
test("should return Buffer", () => {
expect(Buffer.isBuffer(handlebarsParse(blankConf, "test"))).toBe(true)
})
test("should handle Buffer input", () => {
expect(handlebarsParse(blankConf, Buffer.from("Hello {{name}}")).toString()).toEqual("Hello test")
})
test("should return original content on handlebars error", () => {
const result = handlebarsParse(blankConf, "{{#if}}invalid{{/unless}}")
expect(Buffer.isBuffer(result)).toBe(true)
expect(result.toString()).toEqual("{{#if}}invalid{{/unless}}")
})
test("should handle empty template", () => {
expect(handlebarsParse(blankConf, "").toString()).toEqual("")
})
test("should handle template with no tokens", () => {
expect(handlebarsParse(blankConf, "no tokens here").toString()).toEqual("no tokens here")
})
test("should not escape HTML chars (noEscape)", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "<div>test</div>" },
}
expect(handlebarsParse(config, "{{name}}").toString()).toEqual("<div>test</div>")
})
test("should handle nested data", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", nested: { key: "value" } },
}
expect(handlebarsParse(config, "{{nested.key}}").toString()).toEqual("value")
})
test("should handle handlebars conditionals", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", showExtra: true },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}} content").toString()).toEqual("extra content")
})
test("should handle handlebars conditionals when false", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", showExtra: false },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#if showExtra}}extra{{/if}}content").toString()).toEqual("content")
})
test("should handle handlebars each loops", () => {
const config: ScaffoldConfig = {
...blankConf,
data: { name: "test", items: ["a", "b", "c"] },
}
registerHelpers(config)
expect(handlebarsParse(config, "{{#each items}}{{this}},{{/each}}").toString()).toEqual("a,b,c,")
})
test("should render empty for undefined data token", () => {
expect(handlebarsParse(blankConf, "{{undefinedVar}}").toString()).toEqual("")
})
})
describe("Helpers", () => {
@@ -111,6 +194,91 @@ describe("parser", () => {
})
})
describe("string helpers edge cases", () => {
test("camelCase single word", () => {
expect(defaultHelpers.camelCase("hello")).toEqual("hello")
})
test("camelCase empty string", () => {
expect(defaultHelpers.camelCase("")).toEqual("")
})
test("camelCase all uppercase", () => {
expect(defaultHelpers.camelCase("HELLO WORLD")).toEqual("helloWorld")
})
test("pascalCase single word", () => {
expect(defaultHelpers.pascalCase("hello")).toEqual("Hello")
})
test("pascalCase empty string", () => {
expect(defaultHelpers.pascalCase("")).toEqual("")
})
test("snakeCase single word", () => {
expect(defaultHelpers.snakeCase("hello")).toEqual("hello")
})
test("snakeCase empty string", () => {
expect(defaultHelpers.snakeCase("")).toEqual("")
})
test("kebabCase single word", () => {
expect(defaultHelpers.kebabCase("hello")).toEqual("hello")
})
test("kebabCase empty string", () => {
expect(defaultHelpers.kebabCase("")).toEqual("")
})
test("startCase single word", () => {
expect(defaultHelpers.startCase("hello")).toEqual("Hello")
})
test("startCase empty string", () => {
expect(defaultHelpers.startCase("")).toEqual("")
})
test("hyphenCase is same as kebabCase", () => {
expect(defaultHelpers.hyphenCase("testString")).toEqual(defaultHelpers.kebabCase("testString"))
expect(defaultHelpers.hyphenCase("test_string")).toEqual(defaultHelpers.kebabCase("test_string"))
})
test("lowerCase lowercases everything", () => {
expect(defaultHelpers.lowerCase("HELLO")).toEqual("hello")
expect(defaultHelpers.lowerCase("Hello World")).toEqual("hello world")
})
test("upperCase uppercases everything", () => {
expect(defaultHelpers.upperCase("hello")).toEqual("HELLO")
expect(defaultHelpers.upperCase("hello world")).toEqual("HELLO WORLD")
})
test("camelCase handles numbers in string", () => {
expect(defaultHelpers.camelCase("item1_name")).toEqual("item1Name")
})
test("pascalCase handles multiple separators", () => {
expect(defaultHelpers.pascalCase("a--b__c d")).toEqual("ABCD")
})
test("snakeCase handles mixed separators", () => {
expect(defaultHelpers.snakeCase("myApp-name_here")).toEqual("my_app_name_here")
})
test("kebabCase handles mixed separators", () => {
expect(defaultHelpers.kebabCase("myApp-name_here")).toEqual("my-app-name-here")
})
test("single character inputs", () => {
expect(defaultHelpers.camelCase("a")).toEqual("a")
expect(defaultHelpers.pascalCase("a")).toEqual("A")
expect(defaultHelpers.snakeCase("a")).toEqual("a")
expect(defaultHelpers.kebabCase("a")).toEqual("a")
expect(defaultHelpers.startCase("a")).toEqual("A")
})
})
describe("date helpers", () => {
describe("now", () => {
test("should work without extra params", () => {
@@ -140,7 +308,122 @@ describe("parser", () => {
dateFns.format(dateFns.add(now, { months: 1 }), fmt),
)
})
test("should work with years offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "yyyy", 1, "years")).toEqual(
dateFns.format(dateFns.add(date, { years: 1 }), "yyyy"),
)
})
test("should work with weeks offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "yyyy-MM-dd", 2, "weeks")).toEqual(
dateFns.format(dateFns.add(date, { weeks: 2 }), "yyyy-MM-dd"),
)
})
test("should work with minutes offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "HH:mm", 30, "minutes")).toEqual(
dateFns.format(dateFns.add(date, { minutes: 30 }), "HH:mm"),
)
})
test("should work with seconds offset", () => {
const dateStr = "2024-01-15T12:00:00.000Z"
const date = dateFns.parseISO(dateStr)
expect(dateHelper(dateStr, "HH:mm:ss", 45, "seconds")).toEqual(
dateFns.format(dateFns.add(date, { seconds: 45 }), "HH:mm:ss"),
)
})
})
describe("now edge cases", () => {
test("should work with different format tokens", () => {
const now = new Date()
expect(nowHelper("yyyy")).toEqual(dateFns.format(now, "yyyy"))
expect(nowHelper("MM")).toEqual(dateFns.format(now, "MM"))
expect(nowHelper("dd")).toEqual(dateFns.format(now, "dd"))
})
test("should work with positive offset", () => {
const now = new Date()
const result = nowHelper("yyyy-MM-dd", 1, "days")
const expected = dateFns.format(dateFns.add(now, { days: 1 }), "yyyy-MM-dd")
expect(result).toEqual(expected)
})
test("should work with hours offset", () => {
const now = new Date()
const result = nowHelper("HH", 2, "hours")
const expected = dateFns.format(dateFns.add(now, { hours: 2 }), "HH")
expect(result).toEqual(expected)
})
})
})
})
describe("registerHelpers", () => {
test("registers default helpers", () => {
const config: ScaffoldConfig = { ...blankConf }
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "hello_world" } },
"{{camelCase name}}",
)
expect(result.toString()).toEqual("helloWorld")
})
test("registers custom helpers", () => {
const config: ScaffoldConfig = {
...blankConf,
helpers: {
reverse: (text: string) => text.split("").reverse().join(""),
},
}
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "hello" } },
"{{reverse name}}",
)
expect(result.toString()).toEqual("olleh")
})
test("custom helpers override default helpers", () => {
const config: ScaffoldConfig = {
...blankConf,
helpers: {
camelCase: () => "OVERRIDDEN",
},
}
registerHelpers(config)
const result = handlebarsParse(
{ ...config, data: { name: "test" } },
"{{camelCase name}}",
)
expect(result.toString()).toEqual("OVERRIDDEN")
})
})
describe("default helpers completeness", () => {
test("all expected helpers are defined", () => {
const expectedHelpers = [
"camelCase", "snakeCase", "startCase", "kebabCase",
"hyphenCase", "pascalCase", "lowerCase", "upperCase",
"now", "date",
]
for (const helper of expectedHelpers) {
expect(defaultHelpers).toHaveProperty(helper)
expect(typeof defaultHelpers[helper as keyof typeof defaultHelpers]).toBe("function")
}
})
test("has exactly 10 helpers", () => {
expect(Object.keys(defaultHelpers).length).toBe(10)
})
})
})

529
tests/prompts.test.ts Normal file
View File

@@ -0,0 +1,529 @@
import { describe, test, expect, vi, beforeEach } from "vitest"
import { LogLevel, ScaffoldCmdConfig, ScaffoldConfig } from "../src/types"
vi.mock("@inquirer/input", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/select", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/confirm", () => ({
default: vi.fn(),
}))
vi.mock("@inquirer/number", () => ({
default: vi.fn(),
}))
import inputMock from "@inquirer/input"
import selectMock from "@inquirer/select"
import confirmMock from "@inquirer/confirm"
import numberMock from "@inquirer/number"
import {
promptForName,
promptForTemplateKey,
promptForOutput,
promptForTemplates,
promptForMissingConfig,
promptForInputs,
resolveInputs,
isInteractive,
} from "../src/prompts"
function mockTTY(value: boolean) {
Object.defineProperty(process.stdin, "isTTY", { value, configurable: true })
}
const blankConfig: ScaffoldCmdConfig = {
logLevel: LogLevel.none,
name: "",
output: "",
templates: [],
data: {},
overwrite: false,
subdir: false,
dryRun: false,
quiet: false,
version: false,
}
describe("prompts", () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe("promptForName", () => {
test("calls input prompt and returns result", async () => {
vi.mocked(inputMock).mockResolvedValue("my-component")
const result = await promptForName()
expect(result).toEqual("my-component")
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForTemplateKey", () => {
test("calls select prompt when multiple keys", async () => {
vi.mocked(selectMock).mockResolvedValue("component")
const result = await promptForTemplateKey({
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
})
expect(result).toEqual("component")
expect(selectMock).toHaveBeenCalledOnce()
})
test("returns single key without prompting", async () => {
const result = await promptForTemplateKey({
default: { name: "d", templates: [], output: "" },
})
expect(result).toEqual("default")
expect(selectMock).not.toHaveBeenCalled()
})
test("throws when config map is empty", async () => {
await expect(promptForTemplateKey({})).rejects.toThrow("No templates found")
})
test("presents all keys as choices", async () => {
vi.mocked(selectMock).mockResolvedValue("b")
await promptForTemplateKey({
a: { name: "a", templates: [], output: "" },
b: { name: "b", templates: [], output: "" },
c: { name: "c", templates: [], output: "" },
})
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "a", value: "a" },
{ name: "b", value: "b" },
{ name: "c", value: "c" },
])
})
})
describe("promptForOutput", () => {
test("calls input prompt and returns result", async () => {
vi.mocked(inputMock).mockResolvedValue("./dist")
const result = await promptForOutput()
expect(result).toEqual("./dist")
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForTemplates", () => {
test("parses comma-separated input into array", async () => {
vi.mocked(inputMock).mockResolvedValue("src/templates, lib/other")
const result = await promptForTemplates()
expect(result).toEqual(["src/templates", "lib/other"])
})
test("handles single template", async () => {
vi.mocked(inputMock).mockResolvedValue("src/templates")
const result = await promptForTemplates()
expect(result).toEqual(["src/templates"])
})
test("trims whitespace and filters empty entries", async () => {
vi.mocked(inputMock).mockResolvedValue(" a , , b , ")
const result = await promptForTemplates()
expect(result).toEqual(["a", "b"])
})
})
describe("isInteractive", () => {
test("returns a boolean", () => {
expect(typeof isInteractive()).toBe("boolean")
})
})
describe("promptForMissingConfig", () => {
test("prompts for all missing values when interactive", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("my-app") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("my-app")
expect(result.output).toEqual("./output")
expect(result.templates).toEqual(["src/tpl"])
expect(inputMock).toHaveBeenCalledTimes(3)
})
test("does not prompt for values already provided", async () => {
mockTTY(true)
const config = {
...blankConfig,
name: "already-set",
output: "./out",
templates: ["tpl"],
}
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("already-set")
expect(result.output).toEqual("./out")
expect(result.templates).toEqual(["tpl"])
expect(inputMock).not.toHaveBeenCalled()
})
test("prompts for template key when multiple templates and no key", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("name") // name
.mockResolvedValueOnce("./output") // output
.mockResolvedValueOnce("src/tpl") // templates
vi.mocked(selectMock).mockResolvedValue("component")
const configMap = {
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
}
const config = { ...blankConfig }
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toEqual("component")
})
test("does not prompt for template key when already set", async () => {
mockTTY(true)
const configMap = {
default: { name: "d", templates: [], output: "" },
component: { name: "c", templates: [], output: "" },
}
const config = {
...blankConfig,
name: "test",
output: "./out",
templates: ["tpl"],
key: "default",
}
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toEqual("default")
expect(selectMock).not.toHaveBeenCalled()
})
test("does not prompt for template key when only one template", async () => {
mockTTY(true)
const configMap = {
default: { name: "d", templates: [], output: "" },
}
const config = { ...blankConfig, name: "test", output: "./out", templates: ["tpl"] }
const result = await promptForMissingConfig(config, configMap)
expect(result.key).toBeUndefined()
expect(selectMock).not.toHaveBeenCalled()
})
test("does not prompt in non-interactive mode", async () => {
mockTTY(false)
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("")
expect(result.output).toEqual("")
expect(result.templates).toEqual([])
expect(inputMock).not.toHaveBeenCalled()
})
test("does not prompt for config key when no config map provided", async () => {
mockTTY(true)
vi.mocked(inputMock)
.mockResolvedValueOnce("name")
.mockResolvedValueOnce("./out")
.mockResolvedValueOnce("tpl")
const config = { ...blankConfig }
const result = await promptForMissingConfig(config)
expect(result.key).toBeUndefined()
expect(selectMock).not.toHaveBeenCalled()
})
test("only prompts for missing values, not provided ones", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("src/tpl") // only templates missing
const config = { ...blankConfig, name: "app", output: "./out" }
const result = await promptForMissingConfig(config)
expect(result.name).toEqual("app")
expect(result.output).toEqual("./out")
expect(result.templates).toEqual(["src/tpl"])
expect(inputMock).toHaveBeenCalledOnce()
})
})
describe("promptForInputs", () => {
test("prompts for required inputs not in existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{},
)
expect(result.author).toEqual("John")
expect(inputMock).toHaveBeenCalledOnce()
})
test("skips inputs already provided in data", async () => {
const result = await promptForInputs(
{ author: { message: "Author name", required: true } },
{ author: "Jane" },
)
expect(result.author).toEqual("Jane")
expect(inputMock).not.toHaveBeenCalled()
})
test("applies default value for optional inputs not in data", async () => {
const result = await promptForInputs({ license: { default: "MIT" } }, {})
expect(result.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not apply default when value already exists", async () => {
const result = await promptForInputs(
{ license: { default: "MIT" } },
{ license: "Apache-2.0" },
)
expect(result.license).toEqual("Apache-2.0")
})
test("uses input key as message fallback", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("val")
await promptForInputs({ myField: { required: true } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { message: string }
expect(call.message).toContain("myField")
})
test("prompts multiple required inputs in order", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John").mockResolvedValueOnce("2.0")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
version: { message: "Version", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.version).toEqual("2.0")
expect(inputMock).toHaveBeenCalledTimes(2)
})
test("mixes prompts, defaults, and existing data", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
const result = await promptForInputs(
{
author: { message: "Author", required: true },
license: { default: "MIT" },
description: { message: "Desc", required: true },
},
{ description: "My project" },
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.description).toEqual("My project")
expect(inputMock).toHaveBeenCalledOnce()
})
test("preserves existing data keys not in inputs", async () => {
const result = await promptForInputs({ license: { default: "MIT" } }, { extra: "value" })
expect(result.extra).toEqual("value")
expect(result.license).toEqual("MIT")
})
test("required input with default pre-fills prompt", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("custom")
await promptForInputs({ author: { required: true, default: "Anonymous" } }, {})
const call = vi.mocked(inputMock).mock.calls[0][0] as { default?: string }
expect(call.default).toEqual("Anonymous")
})
test("select input prompts with options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
const result = await promptForInputs(
{
license: {
type: "select",
message: "License",
options: ["MIT", "Apache-2.0", "GPL-3.0"],
},
},
{},
)
expect(result.license).toEqual("MIT")
expect(selectMock).toHaveBeenCalledOnce()
const call = vi.mocked(selectMock).mock.calls[0][0] as {
choices: { name: string; value: string }[]
}
expect(call.choices).toEqual([
{ name: "MIT", value: "MIT" },
{ name: "Apache-2.0", value: "Apache-2.0" },
{ name: "GPL-3.0", value: "GPL-3.0" },
])
})
test("select input with object options", async () => {
vi.mocked(selectMock).mockResolvedValueOnce("mit")
const result = await promptForInputs(
{
license: {
type: "select",
options: [
{ name: "MIT License", value: "mit" },
{ name: "Apache 2.0", value: "apache" },
],
},
},
{},
)
expect(result.license).toEqual("mit")
})
test("select input throws when no options", async () => {
await expect(promptForInputs({ license: { type: "select" } }, {})).rejects.toThrow(
"no options defined",
)
})
test("select input skipped when value already provided", async () => {
const result = await promptForInputs(
{ license: { type: "select", options: ["MIT", "Apache"] } },
{ license: "MIT" },
)
expect(result.license).toEqual("MIT")
expect(selectMock).not.toHaveBeenCalled()
})
test("confirm input prompts and returns boolean", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(true)
const result = await promptForInputs(
{ private: { type: "confirm", message: "Private?" } },
{},
)
expect(result.private).toBe(true)
expect(confirmMock).toHaveBeenCalledOnce()
})
test("confirm input with default false", async () => {
vi.mocked(confirmMock).mockResolvedValueOnce(false)
await promptForInputs({ private: { type: "confirm", default: false } }, {})
const call = vi.mocked(confirmMock).mock.calls[0][0] as { default?: boolean }
expect(call.default).toBe(false)
})
test("confirm input skipped when value already provided", async () => {
const result = await promptForInputs({ private: { type: "confirm" } }, { private: true })
expect(result.private).toBe(true)
expect(confirmMock).not.toHaveBeenCalled()
})
test("number input prompts and returns number", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(8080)
const result = await promptForInputs(
{ port: { type: "number", message: "Port", required: true } },
{},
)
expect(result.port).toBe(8080)
expect(numberMock).toHaveBeenCalledOnce()
})
test("number input with default", async () => {
vi.mocked(numberMock).mockResolvedValueOnce(3000)
await promptForInputs({ port: { type: "number", default: 3000, required: true } }, {})
const call = vi.mocked(numberMock).mock.calls[0][0] as { default?: number }
expect(call.default).toBe(3000)
})
test("number input skipped when value already provided", async () => {
const result = await promptForInputs(
{ port: { type: "number", required: true } },
{ port: 9090 },
)
expect(result.port).toBe(9090)
expect(numberMock).not.toHaveBeenCalled()
})
test("mixed input types in one config", async () => {
vi.mocked(inputMock).mockResolvedValueOnce("John")
vi.mocked(selectMock).mockResolvedValueOnce("MIT")
vi.mocked(confirmMock).mockResolvedValueOnce(true)
vi.mocked(numberMock).mockResolvedValueOnce(3000)
const result = await promptForInputs(
{
author: { type: "text", message: "Author", required: true },
license: { type: "select", message: "License", options: ["MIT", "Apache"] },
private: { type: "confirm", message: "Private?" },
port: { type: "number", message: "Port", required: true },
},
{},
)
expect(result.author).toEqual("John")
expect(result.license).toEqual("MIT")
expect(result.private).toBe(true)
expect(result.port).toBe(3000)
})
})
describe("resolveInputs", () => {
test("returns config unchanged when no inputs defined", async () => {
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { foo: "bar" },
}
const result = await resolveInputs(config)
expect(result.data).toEqual({ foo: "bar" })
})
test("applies defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("MIT")
expect(inputMock).not.toHaveBeenCalled()
})
test("does not overwrite existing data with defaults in non-interactive mode", async () => {
mockTTY(false)
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: { license: "Apache-2.0" },
inputs: {
license: { default: "MIT" },
},
}
const result = await resolveInputs(config)
expect(result.data?.license).toEqual("Apache-2.0")
})
test("prompts for required inputs in interactive mode", async () => {
mockTTY(true)
vi.mocked(inputMock).mockResolvedValueOnce("John")
const config: ScaffoldConfig = {
name: "test",
output: "out",
templates: [],
data: {},
inputs: {
author: { message: "Author", required: true },
},
}
const result = await resolveInputs(config)
expect(result.data?.author).toEqual("John")
})
})
})

View File

@@ -1,3 +1,14 @@
import {
describe,
test,
expect,
beforeEach,
afterEach,
beforeAll,
afterAll,
vi,
type MockInstance,
} from "vitest"
import mockFs from "mock-fs"
import FileSystem from "mock-fs/lib/filesystem"
import Scaffold from "../src/scaffold"
@@ -66,7 +77,8 @@ const fileStructDates = {
input: {
"now.txt": "Today is {{ now 'mmm' }}, time is {{ now 'HH:mm' }}",
"offset.txt": "Yesterday was {{ now 'mmm' -1 'days' }}, time is {{ now 'HH:mm' -1 'days' }}",
"custom.txt": "Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
"custom.txt":
"Custom date is {{ date customDate 'mmm' }}, time is {{ date customDate 'HH:mm' }}",
},
output: {},
}
@@ -79,14 +91,14 @@ const fileStructExcludes = {
output: {},
}
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunction): jest.EmptyFunction {
function withMock(fileStruct: FileSystem.DirectoryItems, testFn: () => void): () => void {
return () => {
beforeEach(() => {
// console.log("Mocking:", fileStruct)
console = new Console(process.stdout, process.stderr)
mockFs(fileStruct)
// logMock = jest.spyOn(console, 'log').mockImplementation((...args) => {
// logMock = vi.spyOn(console, 'log').mockImplementation((...args) => {
// logsTemp.push(args)
// })
})
@@ -99,7 +111,6 @@ function withMock(fileStruct: FileSystem.DirectoryItems, testFn: jest.EmptyFunct
}
describe("Scaffold", () => {
describe(
"create subdir",
@@ -200,9 +211,9 @@ describe("Scaffold", () => {
describe(
"errors",
withMock(fileStructNormal, () => {
let consoleMock1: jest.SpyInstance
let consoleMock1: MockInstance
beforeAll(() => {
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
})
afterAll(() => {
@@ -238,9 +249,9 @@ describe("Scaffold", () => {
describe(
"dry run",
withMock(fileStructNormal, () => {
let consoleMock1: jest.SpyInstance
let consoleMock1: MockInstance
beforeAll(() => {
consoleMock1 = jest.spyOn(console, "error").mockImplementation(() => void 0)
consoleMock1 = vi.spyOn(console, "error").mockImplementation(() => void 0)
})
afterAll(() => {
@@ -279,7 +290,8 @@ describe("Scaffold", () => {
}),
)
describe("output structure", () => {
describe(
"output structure",
withMock(fileStructNested, () => {
test("should maintain input structure on output", async () => {
await Scaffold({
@@ -299,28 +311,33 @@ describe("Scaffold", () => {
const rootFile = readFileSync(join(process.cwd(), "output", "app_name-1.txt"))
const oneDeepFile = readFileSync(join(process.cwd(), "output", "AppName/app_name-2.txt"))
const twoDeepFile = readFileSync(join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"))
const twoDeepFile = readFileSync(
join(process.cwd(), "output", "AppName/moreNesting/app_name-3.txt"),
)
expect(rootFile.toString()).toEqual("This should be in root")
expect(oneDeepFile.toString()).toEqual("Hello, my value is 1")
expect(twoDeepFile.toString()).toEqual("Hi! My value is actually NOT 1!")
})
})
}),
)
describe(
"file exclusion via glob pattern",
withMock(fileStructExcludes, () => {
test("should exclude files", async () => {
test("should only include matching files", async () => {
await Scaffold({
name: "app_name",
output: "output",
templates: ["input", "!exclude.txt"],
templates: ["input/include.*"],
data: { value: "1" },
logLevel: "none",
})
const includeFile = readFileSync(join(process.cwd(), "output", "app_name.txt"))
expect(includeFile.toString()).toEqual("This file should be included")
expect(() => readFileSync(join(process.cwd(), "output", "exclude.txt"))).toThrow()
const outputFiles = readdirSync(join(process.cwd(), "output"))
expect(outputFiles).toContain("include.txt")
expect(outputFiles).not.toContain("exclude.txt")
})
})
})
}),
)
describe(
"capitalization helpers",
@@ -524,4 +541,705 @@ describe("Scaffold", () => {
})
}),
)
describe(
"name is available in data",
withMock(
{
input: { "file.txt": "Name: {{name}}" },
output: {},
},
() => {
test("name is automatically injected into data", async () => {
await Scaffold({
name: "my_project",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Name: my_project")
})
},
),
)
describe(
"data overrides name in data",
withMock(
{
input: { "file.txt": "Name: {{name}}" },
output: {},
},
() => {
test("explicit data.name takes precedence", async () => {
await Scaffold({
name: "original_name",
output: "output",
templates: ["input"],
logLevel: "none",
data: { name: "custom_name" },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Name: custom_name")
})
},
),
)
describe(
"multiple templates",
withMock(
{
template1: { "file1.txt": "From template 1: {{name}}" },
template2: { "file2.txt": "From template 2: {{name}}" },
output: {},
},
() => {
test("processes multiple template directories", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["template1", "template2"],
logLevel: "none",
})
const file1 = readFileSync(join(process.cwd(), "output", "file1.txt")).toString()
const file2 = readFileSync(join(process.cwd(), "output", "file2.txt")).toString()
expect(file1).toEqual("From template 1: app")
expect(file2).toEqual("From template 2: app")
})
},
),
)
describe(
"template with custom data",
withMock(
{
input: { "{{name}}.txt": "Author: {{author}}, Version: {{version}}" },
output: {},
},
() => {
test("uses custom data in content and filename", async () => {
await Scaffold({
name: "my_app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { author: "John", version: "2.0" },
})
const content = readFileSync(join(process.cwd(), "output", "my_app.txt")).toString()
expect(content).toEqual("Author: John, Version: 2.0")
})
},
),
)
describe(
"template with helpers in filenames",
withMock(
{
input: { "{{pascalCase name}}.tsx": "component {{pascalCase name}}" },
output: {},
},
() => {
test("applies helpers to filenames", async () => {
await Scaffold({
name: "my_component",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(join(process.cwd(), "output", "MyComponent.tsx")).toString()
expect(content).toEqual("component MyComponent")
})
},
),
)
describe(
"template with helpers in directory names",
withMock(
{
input: {
"{{kebabCase name}}": {
"index.ts": "export from {{name}}",
},
},
output: {},
},
() => {
test("applies helpers to directory names", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
})
const content = readFileSync(
join(process.cwd(), "output", "my-component", "index.ts"),
).toString()
expect(content).toEqual("export from MyComponent")
})
},
),
)
describe(
"deeply nested template structure",
withMock(
{
input: {
"root.txt": "root",
level1: {
"l1.txt": "level 1",
level2: {
"l2.txt": "level 2",
level3: {
"l3.txt": "level 3 {{name}}",
},
},
},
},
output: {},
},
() => {
test("preserves deep nesting", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", "root.txt")).toString()).toEqual("root")
expect(
readFileSync(join(process.cwd(), "output", "level1", "l1.txt")).toString(),
).toEqual("level 1")
expect(
readFileSync(join(process.cwd(), "output", "level1", "level2", "l2.txt")).toString(),
).toEqual("level 2")
expect(
readFileSync(
join(process.cwd(), "output", "level1", "level2", "level3", "l3.txt"),
).toString(),
).toEqual("level 3 app")
})
},
),
)
describe(
"overwrite as function",
withMock(
{
input: {
"keep.txt": "new keep",
"replace.txt": "new replace",
},
output: {
"keep.txt": "old keep",
"replace.txt": "old replace",
},
},
() => {
test("per-file overwrite control", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
overwrite: (_fullPath, _basedir, basename) => basename === "replace.txt",
})
expect(readFileSync(join(process.cwd(), "output", "keep.txt")).toString()).toEqual(
"old keep",
)
expect(readFileSync(join(process.cwd(), "output", "replace.txt")).toString()).toEqual(
"new replace",
)
})
},
),
)
describe(
"multiple custom helpers",
withMock(
{
input: {
"file.txt": "{{reverse name}} - {{repeat name}}",
},
output: {},
},
() => {
test("multiple custom helpers work together", async () => {
await Scaffold({
name: "abc",
output: "output",
templates: ["input"],
logLevel: "none",
helpers: {
reverse: (text: string) => text.split("").reverse().join(""),
repeat: (text: string) => text + text,
},
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("cba - abcabc")
})
},
),
)
describe(
"subdirHelper with different helpers",
withMock(
{
input: { "file.txt": "content" },
output: {},
},
() => {
test("subdirHelper camelCase", async () => {
await Scaffold({
name: "my_component",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "camelCase",
})
const content = readFileSync(
join(process.cwd(), "output", "myComponent", "file.txt"),
).toString()
expect(content).toEqual("content")
})
test("subdirHelper kebabCase", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "kebabCase",
})
const content = readFileSync(
join(process.cwd(), "output", "my-component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
test("subdirHelper snakeCase", async () => {
await Scaffold({
name: "MyComponent",
output: "output",
templates: ["input"],
logLevel: "none",
subdir: true,
subdirHelper: "snakeCase",
})
const content = readFileSync(
join(process.cwd(), "output", "my_component", "file.txt"),
).toString()
expect(content).toEqual("content")
})
},
),
)
describe(
"empty template directory",
withMock(
{
input: {},
output: {},
},
() => {
test("handles empty template dir gracefully", async () => {
await expect(
Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
}),
).resolves.toBeUndefined()
})
},
),
)
describe(
"template with special characters in data",
withMock(
{
input: { "file.txt": "Value: {{value}}" },
output: {},
},
() => {
test("handles special characters in data values", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { value: 'hello & <world> "test"' },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual('Value: hello & <world> "test"')
})
},
),
)
describe(
"beforeWrite with async callback",
withMock(
{
input: { "file.txt": "Hello {{name}}" },
output: {},
},
() => {
test("supports async beforeWrite", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
beforeWrite: async (content) => {
return content.toString().replace("Hello", "Hi")
},
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Hi app")
})
},
),
)
describe(
"beforeWrite receives all arguments",
withMock(
{
input: { "{{name}}.txt": "Template: {{name}}" },
output: {},
},
() => {
test("beforeWrite gets content, rawContent, and outputPath", async () => {
const beforeWriteSpy = vi.fn().mockReturnValue(undefined)
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
beforeWrite: beforeWriteSpy,
})
expect(beforeWriteSpy).toHaveBeenCalledTimes(1)
const [content, rawContent, outputPath] = beforeWriteSpy.mock.calls[0]
expect(content.toString()).toEqual("Template: app")
expect(rawContent.toString()).toEqual("Template: {{name}}")
expect(outputPath).toContain("app.txt")
})
},
),
)
describe(
"multiple binary files",
withMock(
{
input: {
"img1.bin": crypto.randomBytes(5000),
"img2.bin": crypto.randomBytes(8000),
"text.txt": "regular text {{name}}",
},
output: {},
},
() => {
test("handles mix of binary and text files", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const text = readFileSync(join(process.cwd(), "output", "text.txt")).toString()
expect(text).toEqual("regular text app")
const bin1 = readFileSync(join(process.cwd(), "output", "img1.bin"))
const bin2 = readFileSync(join(process.cwd(), "output", "img2.bin"))
expect(bin1.length).toBeGreaterThan(0)
expect(bin2.length).toBeGreaterThan(0)
})
},
),
)
describe(
"output with function returning dynamic path",
withMock(
{
input: {
"component.tsx": "component {{name}}",
"style.css": "style for {{name}}",
},
output: {},
},
() => {
test("output function can route files to different directories", async () => {
await Scaffold({
name: "Button",
output: (_fullPath, _basedir, basename) => {
if (basename.endsWith(".css")) return join("output", "styles")
return join("output", "components")
},
templates: ["input"],
logLevel: "none",
})
const component = readFileSync(
join(process.cwd(), "output", "components", "component.tsx"),
).toString()
const style = readFileSync(
join(process.cwd(), "output", "styles", "style.css"),
).toString()
expect(component).toEqual("component Button")
expect(style).toEqual("style for Button")
})
},
),
)
describe(
"handlebars block helpers",
withMock(
{
input: {
"file.txt": "{{#if showHeader}}Header\n{{/if}}Body for {{name}}",
},
output: {},
},
() => {
test("supports handlebars block helpers in templates", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
data: { showHeader: true },
})
const content = readFileSync(join(process.cwd(), "output", "file.txt")).toString()
expect(content).toEqual("Header\nBody for app")
})
},
),
)
describe(
"glob pattern as template",
withMock(
{
src: {
"file1.txt": "text 1 {{name}}",
"file2.txt": "text 2 {{name}}",
"file3.js": "js {{name}}",
},
output: {},
},
() => {
test("glob pattern selects matching files only", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["src/*.txt"],
logLevel: "none",
})
// glob templates maintain structure relative to the non-glob part
const outputFiles = readdirSync(join(process.cwd(), "output", "src"))
expect(outputFiles).toContain("file1.txt")
expect(outputFiles).toContain("file2.txt")
expect(outputFiles).not.toContain("file3.js")
})
},
),
)
describe(
"dotfiles in template",
withMock(
{
input: {
".gitignore": "node_modules",
".env.example": "KEY={{name}}",
},
output: {},
},
() => {
test("includes dotfiles in output", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
expect(readFileSync(join(process.cwd(), "output", ".gitignore")).toString()).toEqual(
"node_modules",
)
expect(readFileSync(join(process.cwd(), "output", ".env.example")).toString()).toEqual(
"KEY=app",
)
})
},
),
)
describe(
"large number of files",
withMock(
{
input: Object.fromEntries(
Array.from({ length: 50 }, (_, i) => [`file${i}.txt`, `Content ${i} for {{name}}`]),
),
output: {},
},
() => {
test("handles many files", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files.length).toBe(50)
expect(readFileSync(join(process.cwd(), "output", "file0.txt")).toString()).toEqual(
"Content 0 for app",
)
expect(readFileSync(join(process.cwd(), "output", "file49.txt")).toString()).toEqual(
"Content 49 for app",
)
})
},
),
)
describe(
".scaffoldignore",
withMock(
{
input: {
".scaffoldignore": "*.log\nREADME.md\n",
"file.txt": "included",
"debug.log": "excluded log",
"README.md": "excluded readme",
"other.js": "included js",
},
output: {},
},
() => {
test("excludes files matching .scaffoldignore patterns", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files).toContain("file.txt")
expect(files).toContain("other.js")
expect(files).not.toContain("debug.log")
expect(files).not.toContain("README.md")
expect(files).not.toContain(".scaffoldignore")
})
},
),
)
describe(
".scaffoldignore not copied to output",
withMock(
{
input: {
".scaffoldignore": "*.log",
"file.txt": "content",
},
output: {},
},
() => {
test(".scaffoldignore is never in output", async () => {
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
})
const files = readdirSync(join(process.cwd(), "output"))
expect(files).not.toContain(".scaffoldignore")
expect(files).toContain("file.txt")
})
},
),
)
describe(
"afterScaffold hook",
withMock(
{
input: { "file.txt": "Hello {{name}}" },
output: {},
},
() => {
test("calls function hook with config and files", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
const ctx = hookFn.mock.calls[0][0]
expect(ctx.config.name).toEqual("app")
expect(ctx.files.length).toBe(1)
expect(ctx.files[0]).toContain("file.txt")
})
test("calls async function hook", async () => {
let called = false
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
afterScaffold: async () => {
called = true
},
})
expect(called).toBe(true)
})
test("does not call hook when no files written (dry run)", async () => {
const hookFn = vi.fn()
await Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
dryRun: true,
afterScaffold: hookFn,
})
expect(hookFn).toHaveBeenCalledOnce()
expect(hookFn.mock.calls[0][0].files.length).toBe(0)
})
test("does not call hook when not provided", async () => {
await expect(
Scaffold({
name: "app",
output: "output",
templates: ["input"],
logLevel: "none",
}),
).resolves.toBeUndefined()
})
},
),
)
})

View File

@@ -1,4 +1,5 @@
import { handleErr, resolve, colorize, TermColor } from "../src/utils"
import { describe, test, expect } from "vitest"
import { handleErr, resolve, wrapNoopResolver, colorize, TermColor } from "../src/utils"
describe("utils", () => {
describe("resolve", () => {
test("should resolve function", () => {
@@ -10,6 +11,45 @@ describe("utils", () => {
expect(resolve(1, null)).toBe(1)
expect(resolve(2, 1)).toBe(2)
})
test("should resolve function with argument transformation", () => {
expect(resolve((x: number) => x * 2, 5)).toBe(10)
})
test("should resolve static string", () => {
expect(resolve("hello", null)).toBe("hello")
})
test("should resolve static boolean", () => {
expect(resolve(true, null)).toBe(true)
expect(resolve(false, null)).toBe(false)
})
test("should resolve static object", () => {
const obj = { key: "value" }
expect(resolve(obj, null)).toBe(obj)
})
test("should resolve function returning object", () => {
expect(resolve(() => ({ key: "value" }), null)).toEqual({ key: "value" })
})
test("should pass argument to function", () => {
const fn = (config: { name: string }) => config.name
expect(resolve(fn, { name: "test" })).toBe("test")
})
test("should resolve zero", () => {
expect(resolve(0, null)).toBe(0)
})
test("should resolve null", () => {
expect(resolve(null, "anything")).toBe(null)
})
test("should resolve undefined", () => {
expect(resolve(undefined, "anything")).toBe(undefined)
})
})
describe("handleErr", () => {
@@ -17,6 +57,44 @@ describe("utils", () => {
expect(() => handleErr({ name: "test", message: "test" })).toThrow()
expect(() => handleErr(null as never)).not.toThrow()
})
test("should throw the provided error", () => {
const err = new Error("test error")
expect(() => handleErr(err as unknown as NodeJS.ErrnoException)).toThrow("test error")
})
})
describe("wrapNoopResolver", () => {
test("should wrap static value in function", () => {
const wrapped = wrapNoopResolver("hello")
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)("anything")).toBe("hello")
})
test("should return function as-is", () => {
const fn = (x: string) => x.toUpperCase()
const wrapped = wrapNoopResolver(fn)
expect(wrapped).toBe(fn)
})
test("should wrap object value", () => {
const obj = { key: "value" }
const wrapped = wrapNoopResolver(obj)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)("anything")).toBe(obj)
})
test("should wrap boolean value", () => {
const wrapped = wrapNoopResolver(true)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)(null)).toBe(true)
})
test("should wrap number value", () => {
const wrapped = wrapNoopResolver(42)
expect(typeof wrapped).toBe("function")
expect((wrapped as (_: unknown) => unknown)(null)).toBe(42)
})
})
})
@@ -66,4 +144,64 @@ describe("colorize", () => {
const result = colorize.blue`Hello ${"World"}`
expect(result).toBe("\x1b[34mHello World\x1b[0m")
})
test("should colorize with green", () => {
expect(colorize("Hello", "green")).toBe("\x1b[32mHello\x1b[0m")
})
test("should colorize with yellow", () => {
expect(colorize("Hello", "yellow")).toBe("\x1b[33mHello\x1b[0m")
})
test("should colorize with magenta", () => {
expect(colorize("Hello", "magenta")).toBe("\x1b[35mHello\x1b[0m")
})
test("should colorize with cyan", () => {
expect(colorize("Hello", "cyan")).toBe("\x1b[36mHello\x1b[0m")
})
test("should colorize with white", () => {
expect(colorize("Hello", "white")).toBe("\x1b[37mHello\x1b[0m")
})
test("should colorize with gray", () => {
expect(colorize("Hello", "gray")).toBe("\x1b[90mHello\x1b[0m")
})
test("should colorize with dim", () => {
expect(colorize("Hello", "dim")).toBe("\x1b[2mHello\x1b[22m")
})
test("should colorize with italic", () => {
expect(colorize("Hello", "italic")).toBe("\x1b[3mHello\x1b[23m")
})
test("should colorize with underline", () => {
expect(colorize("Hello", "underline")).toBe("\x1b[4mHello\x1b[24m")
})
test("color functions work as template strings", () => {
const name = "World"
expect(colorize.green`Hello ${name}`).toBe("\x1b[32mHello World\x1b[0m")
})
test("color functions work with direct call", () => {
expect(colorize.yellow("warning")).toBe("\x1b[33mwarning\x1b[0m")
expect(colorize.cyan("info")).toBe("\x1b[36minfo\x1b[0m")
})
test("handles empty string", () => {
expect(colorize("", "red")).toBe("\x1b[31m\x1b[0m")
})
test("handles special characters", () => {
expect(colorize("hello\nworld", "blue")).toBe("\x1b[34mhello\nworld\x1b[0m")
})
test("template string with multiple interpolations", () => {
const a = "one"
const b = "two"
expect(colorize.red`${a} and ${b}`).toBe("\x1b[31mone and two\x1b[0m")
})
})

231
tests/validate.test.ts Normal file
View File

@@ -0,0 +1,231 @@
import { describe, test, expect, beforeEach, afterEach } from "vitest"
import mockFs from "mock-fs"
import { Console } from "console"
import { validateConfig, validateTemplatePaths, assertConfigValid } from "../src/validate"
const validConfig = {
name: "test",
templates: ["templates"],
output: "output",
}
describe("validate", () => {
describe("validateConfig", () => {
test("returns no errors for valid config", () => {
expect(validateConfig(validConfig)).toEqual([])
})
test("returns no errors with all optional fields", () => {
const errors = validateConfig({
...validConfig,
subdir: true,
subdirHelper: "camelCase",
data: { key: "value" },
logLevel: "debug",
dryRun: true,
overwrite: true,
inputs: {
author: { type: "text", message: "Author", required: true },
},
})
expect(errors).toEqual([])
})
test("errors on missing name", () => {
const errors = validateConfig({ ...validConfig, name: "" })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("name")
})
test("errors on missing templates", () => {
const errors = validateConfig({ ...validConfig, templates: [] })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("templates")
})
test("errors on invalid logLevel", () => {
const errors = validateConfig({ ...validConfig, logLevel: "verbose" })
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("logLevel")
})
test("errors on subdirHelper without subdir", () => {
const errors = validateConfig({
...validConfig,
subdirHelper: "camelCase",
})
expect(errors.length).toBeGreaterThan(0)
expect(errors[0]).toContain("subdirHelper")
})
test("no error on subdirHelper with subdir", () => {
const errors = validateConfig({
...validConfig,
subdir: true,
subdirHelper: "camelCase",
})
expect(errors).toEqual([])
})
test("errors on select input without options", () => {
const errors = validateConfig({
...validConfig,
inputs: {
license: { type: "select" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("select") && e.includes("options"))).toBe(true)
})
test("no error on select input with options", () => {
const errors = validateConfig({
...validConfig,
inputs: {
license: { type: "select", options: ["MIT", "Apache"] },
},
})
expect(errors).toEqual([])
})
test("errors on confirm input with non-boolean default", () => {
const errors = validateConfig({
...validConfig,
inputs: {
flag: { type: "confirm", default: "yes" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("confirm") && e.includes("boolean"))).toBe(true)
})
test("errors on number input with non-number default", () => {
const errors = validateConfig({
...validConfig,
inputs: {
port: { type: "number", default: "3000" },
},
})
expect(errors.length).toBeGreaterThan(0)
expect(errors.some((e) => e.includes("number"))).toBe(true)
})
test("valid input types pass", () => {
const errors = validateConfig({
...validConfig,
inputs: {
a: { type: "text", required: true },
b: { type: "select", options: ["x", "y"] },
c: { type: "confirm", default: false },
d: { type: "number", default: 42 },
},
})
expect(errors).toEqual([])
})
test("accepts function output", () => {
const errors = validateConfig({
...validConfig,
output: () => "dynamic-output",
})
expect(errors).toEqual([])
})
test("accepts function overwrite", () => {
const errors = validateConfig({
...validConfig,
overwrite: () => true,
})
expect(errors).toEqual([])
})
test("accepts afterScaffold string", () => {
const errors = validateConfig({
...validConfig,
afterScaffold: "npm install",
})
expect(errors).toEqual([])
})
test("accepts afterScaffold function", () => {
const errors = validateConfig({
...validConfig,
afterScaffold: () => {},
})
expect(errors).toEqual([])
})
})
describe("validateTemplatePaths", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs({
templates: { "file.txt": "content" },
})
})
afterEach(() => {
mockFs.restore()
})
test("returns no errors for existing paths", async () => {
const errors = await validateTemplatePaths(["templates"])
expect(errors).toEqual([])
})
test("returns error for missing paths", async () => {
const errors = await validateTemplatePaths(["nonexistent"])
expect(errors.length).toBe(1)
expect(errors[0]).toContain("nonexistent")
})
test("skips glob patterns", async () => {
const errors = await validateTemplatePaths(["templates/**/*"])
expect(errors).toEqual([])
})
test("skips negation patterns", async () => {
const errors = await validateTemplatePaths(["!excluded"])
expect(errors).toEqual([])
})
})
describe("assertConfigValid", () => {
beforeEach(() => {
console = new Console(process.stdout, process.stderr)
mockFs({
templates: { "file.txt": "content" },
})
})
afterEach(() => {
mockFs.restore()
})
test("does not throw for valid config", async () => {
await expect(assertConfigValid(validConfig)).resolves.toBeUndefined()
})
test("throws formatted error for invalid config", async () => {
await expect(assertConfigValid({ name: "", templates: [], output: "" })).rejects.toThrow(
"Invalid scaffold config",
)
})
test("includes all errors in message", async () => {
try {
await assertConfigValid({ name: "", templates: [], output: "out" })
} catch (e) {
const msg = (e as Error).message
expect(msg).toContain("name")
expect(msg).toContain("templates")
}
})
test("checks template path existence", async () => {
await expect(
assertConfigValid({ name: "test", templates: ["missing"], output: "out" }),
).rejects.toThrow("does not exist")
})
})
})

44
vite.config.ts Normal file
View File

@@ -0,0 +1,44 @@
import { defineConfig } from "vite"
import path from "node:path"
export default defineConfig({
build: {
lib: {
entry: {
index: path.resolve(__dirname, "src/index.ts"),
cmd: path.resolve(__dirname, "src/cmd.ts"),
},
formats: ["cjs"],
},
outDir: "dist",
target: "node20",
rollupOptions: {
// Externalize all node builtins and all dependencies
external: [
/^node:/, // Node builtins
/^[^./]/, // All bare imports (dependencies)
],
output: {
exports: "named",
},
},
sourcemap: true,
minify: false,
},
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
test: {
globals: true,
clearMocks: true,
coverage: {
enabled: true,
provider: "v8",
reportsDirectory: "coverage",
exclude: ["node_modules/", "scaffold.config.js", "dist/", "docs/"],
},
exclude: ["node_modules", "dist", "docs"],
},
})