Crust logoCrust

Create

Headless scaffolding engine for building project generators

@crustjs/create copies template directories, interpolates variables, renames dotfiles, resolves conflicts, and runs post-scaffold steps. It does not provide prompts or terminal UI.

Install

npm install @crustjs/create

Pair it with any CLI and prompt libraries needed by your create tool.

Quick example

src/index.ts
import { runSteps, scaffold } from "@crustjs/create";

await scaffold({
	template: new URL("../templates/base", import.meta.url),
	dest: "./my-project",
	context: { name: "my-app" },
});

await runSteps([{ type: "install" }, { type: "git-init" }], "./my-project");

scaffold() returns every written path relative to the destination.

Locating templates

template valueResolves fromUse for
new URL("../templates/base", import.meta.url)The module that contains the callTemplates shipped inside your generator package
"./templates/base"The user's current working directoryTemplates in the project where the command runs
"/srv/templates/base"That exact filesystem pathA location already resolved by your own code or configuration

Ship templates inside your generator with an import.meta.url URL: npx, bun x, and global installs run the generator from a cache while the working directory is the user's project, so a relative string would resolve there instead. Convert an existing filesystem path with pathToFileURL() rather than concatenating file://. Only file: URLs are accepted, and the template must be a directory.

Template conventions

templates/base/package.json
{
  "name": "{{name}}",
  "description": "{{description}}"
}

Text files replace {{identifier}} placeholders from context; missing values remain unchanged. Files with a null byte in their first 8 KiB are copied without interpolation.

Custom renderer

Pass render to use a template engine instead of {{identifier}} interpolation. It receives each text file's contents and context, may return a string or a promise, and its output is written as-is without a second interpolation pass. Binary files never reach it.

await scaffold({
  template: new URL("../templates/base", import.meta.url),
  dest: "./my-project",
  context: { name: "my-app" },
  render: (source, context) => source.replaceAll("<%= name %>", context.name ?? ""),
});

Templates run through render as trusted code. Destination conflict and symlink checks still happen before any file is rendered; an error thrown or rejected by render fails scaffold(), and files written earlier in the call remain.

templates/base/_gitignore   -> my-project/.gitignore
templates/base/__tests__/   -> my-project/__tests__/

A single leading _ on a filename becomes ., while __ stays unchanged. Call scaffold() more than once with conflict: "overwrite" to layer templates.

scaffold(options)

Prop

Type

Prop

Type

See Locating templates for how template resolves.

The default conflict: "abort" throws for a non-empty destination. "overwrite" replaces conflicting files and leaves unrelated destination files in place.

dest itself may be a symlink (its target is the destination), but an existing destination file or ancestor directory that is a symlink resolving outside that directory, or to a missing target, throws before any file is written. Symlinks that stay inside the destination are followed.

Containment is checked again after rendering and before each write, including binary copies. A detected redirect of the canonical destination root or an escaping link introduced during rendering aborts the call; earlier writes remain. These path checks are not atomic with filesystem writes: use a destination whose directories cannot be concurrently modified by untrusted processes.

runSteps(steps, cwd)

await runSteps(
  [{ type: "install" }, { type: "git-init", commit: "Initial commit" }],
  "./my-project",
);

Steps run in order and stop at the first error.

Prop

Type

StepBehavior
installDetect the package manager and install dependencies
git-initInitialize Git and optionally create a commit
open-editorOpen $EDITOR or VS Code; warn instead of failing when unavailable
commandRun cmd through the platform shell, optionally in a different cwd

Utilities

interpolate("Hello, {{name}}!", { name: "world" }); // => "Hello, world!"

interpolate() replaces placeholders in a string; it is the default render. detectPackageManager(cwd?) checks lockfiles, then npm_config_user_agent, and defaults to npm; isInGitRepo(cwd?) reports whether the path is inside a Git work tree.

PackageManager is the type detectPackageManager() returns. The package entry point is the complete export list.

On this page