Crust logoCrust

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/create

Quick Example

src/index.ts
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

  1. You organize template files as real files on disk (not string constants).
  2. scaffold() copies the template directory to a destination, applying {{var}} interpolation to text files and renaming _-prefixed files to dotfiles.
  3. Binary files are detected automatically and copied as-is without interpolation.
  4. 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:

templates/base/package.json
{
  "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 fileScaffolded 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

FieldTypeRequiredDescription
templatestring | URLYesTemplate 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.
deststringYesAbsolute or relative path to the destination directory
contextRecord<string, string>YesVariables to interpolate into {{key}} placeholders
conflict"abort" | "overwrite"NoHow to handle an existing non-empty destination. Default: "abort"

Returns

Promise<ScaffoldResult> — an object with:

FieldTypeDescription
filesreadonly 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

FieldTypeDescription
stepsPostScaffoldStep[]Array of step objects to execute in order
cwdstringWorking directory for steps (typically the scaffold destination)

Step Types

TypeFieldsDescription
installDetect the package manager and run its install command
git-initcommit?: stringRun git init, optionally stage all files and create an initial commit
open-editorOpen the project in $EDITOR or VS Code. Does not throw if the editor is not found
commandcmd: string, cwd?: stringRun 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):

  1. Check for lockfiles (bun.lock / pnpm-lock.yaml / yarn.lock / package-lock.json)
  2. Parse the npm_config_user_agent environment variable
  3. 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

FunctionDescription
scaffoldCopy and interpolate a template directory
runStepsExecute post-scaffold automation steps
interpolateReplace {{var}} placeholders in a string
detectPackageManagerDetect bun/npm/pnpm/yarn from lockfiles or user agent
isGitInstalledCheck if git is available
getGitUserRead git user.name and user.email

Types

TypeDescription
ScaffoldOptionsConfiguration for scaffold()
ScaffoldResultResult returned by scaffold()
PostScaffoldStepDiscriminated 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/style dependency)
  • Template-level conditionals or loops ({{#if}}, {{#each}})
  • Dynamic filename interpolation (filenames are static)

On this page