Create
Headless scaffolding engine for building create-xx tools.
@crustjs/create is a headless, zero-dependency scaffolding engine for building npm create-xxx / bun create-xxx tools. It handles the mechanical parts of scaffolding — copying template directories, interpolating variables, renaming dotfiles, resolving conflicts, and running post-scaffold steps — so you focus purely on template design and interactive flow.
The library is headless: it has no opinions about prompts, CLI frameworks, or terminal UI. Pair it with @crustjs/core for command routing and @crustjs/prompts for interactive input.
Install
bun add @crustjs/createQuick Example
import { scaffold, runSteps } from "@crustjs/create";
// 1. Copy and interpolate the template
const result = await scaffold({
template: "./templates/base",
dest: "./my-project",
context: { name: "my-app", description: "A cool CLI" },
});
console.log("Created files:", result.files);
// 2. Run post-scaffold automation
await runSteps(
[
{ type: "install" },
{ type: "git-init", commit: "Initial commit" },
{ type: "open-editor" },
],
"./my-project",
);How It Works
- You organize template files as real files on disk (not string constants).
scaffold()copies the template directory to a destination, applying{{var}}interpolation to text files and renaming_-prefixed files to dotfiles.- Binary files are detected automatically and copied as-is without interpolation.
runSteps()executes post-scaffold automation declaratively — install dependencies, init git, open an editor.
Template Conventions
Variable Interpolation
Template files can contain {{var}} placeholders that are replaced with values from the context object:
{
"name": "{{name}}",
"description": "{{description}}",
"author": "{{author}}"
}Only simple {{identifier}} patterns are supported — no conditionals, loops, or helpers. Whitespace inside braces is tolerated ({{ name }}). Missing variables are left untouched.
Dotfile Renaming
Files prefixed with _ are renamed to start with . during scaffold. This works around npm's behavior of stripping dotfiles during npm publish:
| Template file | Scaffolded as |
|---|---|
_gitignore | .gitignore |
_env.example | .env.example |
Files starting with __ (double underscore) are left unchanged, so directories like __tests__ and __mocks__ are preserved.
Binary Files
Binary files (images, fonts, compiled assets) are detected via null-byte check and copied as-is without interpolation.
Template Composition
Call scaffold() multiple times to layer templates — for example, a base template followed by a TypeScript-specific overlay:
// Base template
await scaffold({
template: "./templates/base",
dest: projectDir,
context,
});
// TypeScript overlay
await scaffold({
template: "./templates/typescript",
dest: projectDir,
context,
conflict: "overwrite",
});API
scaffold(options)
Copy a template directory to a destination, applying variable interpolation and dotfile renaming.
Parameters
| Field | Type | Required | Description |
|---|---|---|---|
template | string | URL | Yes | Template directory. Absolute string paths are used as-is; relative string paths resolve from the nearest package root of process.argv[1]. URL must be a file: URL. |
dest | string | Yes | Absolute or relative path to the destination directory |
context | Record<string, string> | Yes | Variables to interpolate into {{key}} placeholders |
conflict | "abort" | "overwrite" | No | How to handle an existing non-empty destination. Default: "abort" |
Returns
Promise<ScaffoldResult> — an object with:
| Field | Type | Description |
|---|---|---|
files | readonly string[] | All written file paths, relative to the destination directory |
Throws
When conflict is "abort" (default) and the destination is a non-empty directory.
runSteps(steps, cwd)
Execute an array of post-scaffold steps sequentially. If any step fails, the error propagates immediately and remaining steps are skipped.
Parameters
| Field | Type | Description |
|---|---|---|
steps | PostScaffoldStep[] | Array of step objects to execute in order |
cwd | string | Working directory for steps (typically the scaffold destination) |
Step Types
| Type | Fields | Description |
|---|---|---|
install | — | Detect the package manager and run its install command |
git-init | commit?: string | Run git init, optionally stage all files and create an initial commit |
open-editor | — | Open the project in $EDITOR or VS Code. Does not throw if the editor is not found |
command | cmd: string, cwd?: string | Run an arbitrary shell command. Overrides the default cwd if provided |
interpolate(content, context)
Interpolate {{var}} placeholders in a string with values from a context object.
import { interpolate } from "@crustjs/create";
interpolate("Hello, {{name}}!", { name: "world" });
// => "Hello, world!"detectPackageManager(cwd?)
Detect the package manager for a project directory.
Detection strategy (first match wins):
- Check for lockfiles (
bun.lock/pnpm-lock.yaml/yarn.lock/package-lock.json) - Parse the
npm_config_user_agentenvironment variable - Default to
"npm"
Returns a PackageManager — "bun", "pnpm", "yarn", or "npm".
isGitInstalled()
Check whether git is available on the system PATH. Returns boolean.
getGitUser()
Read the current git user's name and email from git config.
Returns { name: string | null, email: string | null }.
Exports
Functions
| Function | Description |
|---|---|
scaffold | Copy and interpolate a template directory |
runSteps | Execute post-scaffold automation steps |
interpolate | Replace {{var}} placeholders in a string |
detectPackageManager | Detect bun/npm/pnpm/yarn from lockfiles or user agent |
isGitInstalled | Check if git is available |
getGitUser | Read git user.name and user.email |
Types
| Type | Description |
|---|---|
ScaffoldOptions | Configuration for scaffold() |
ScaffoldResult | Result returned by scaffold() |
PostScaffoldStep | Discriminated union of step objects for runSteps() |
PackageManager | "npm" | "pnpm" | "bun" | "yarn" |
Design
@crustjs/create is the scaffolding primitive between the CLI layer (@crustjs/core) and the interactive layer (@crustjs/prompts). Templates live as real files on disk, composition is explicit via multiple scaffold() calls, and post-scaffold automation is declarative.
Not included (by design):
- Interactive prompts (belongs in
@crustjs/prompts) - Terminal UI / styled output (headless — no
@crustjs/styledependency) - Template-level conditionals or loops (
{{#if}},{{#each}}) - Dynamic filename interpolation (filenames are static)