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/createPair it with any CLI and prompt libraries needed by your create tool.
Quick example
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 value | Resolves from | Use for |
|---|---|---|
new URL("../templates/base", import.meta.url) | The module that contains the call | Templates shipped inside your generator package |
"./templates/base" | The user's current working directory | Templates in the project where the command runs |
"/srv/templates/base" | That exact filesystem path | A 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
{
"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
| Step | Behavior |
|---|---|
install | Detect the package manager and install dependencies |
git-init | Initialize Git and optionally create a commit |
open-editor | Open $EDITOR or VS Code; warn instead of failing when unavailable |
command | Run 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.