Mutation verbs
Every author verb lives on @pbuilder/sdk/commons — the same entry every schematic imports
from. Each verb schedules a directive; nothing touches disk until the run flushes (on the
next read(), or at run end). Disk writes happen only in the engine’s apply phase, after
your factory returns — a thrown error before return means nothing is written at all.
The seven verbs share one closed AuthoringVerb label union: create, replaceContent,
remove, rename, move, copy, and copyIn — the last a by-reference sibling of copy
that copies straight from the package rather than rendering a template. (scaffold is an
eighth, separately shipped mutation that fans out into create/copyIn directives per
entry — it has its own guide.)
At a glance
Section titled “At a glance”| Verb | What it schedules | Returns | On an existing target |
|---|---|---|---|
create |
A new file, rendered from a template | WritableHandle |
Rejects (path-collision) unless { force: true } |
replaceContent |
Wholesale content replacement of an existing file | WritableHandle |
Target must exist — path-not-found otherwise |
remove |
A file deletion | void |
Idempotent — never rejects |
rename |
A basename-only rename | WritableHandle |
Rejects (path-collision) unless { force: true } |
move |
A move to a different directory | WritableHandle |
Rejects (path-collision) unless { force: true } |
copy |
A tree-to-tree copy | WritableHandle |
Rejects (path-collision) unless { force: true } |
copyIn |
A package-local file copied into the tree verbatim | void |
Rejects (path-collision) unless { force: true } |
modify |
A structural, AST-aware edit — reached through dialects | dialect handle | Target must exist and parse |
The verbs that write to a new path are fail-closed on collision and accept { force: true }
for a deliberate overwrite — create inside its options object, the rest as a trailing
argument. Rejections surface as a structured AuthoringError — see the
error handling guide.
create
Section titled “create”function create<S>( path: string, opts: { template: string; options: { [K in keyof S]: S[K] }; force?: boolean }): WritableHandle;function create(path: string, opts: { template: string; options: JsonValue; force?: boolean }): WritableHandle;function create( path: string, opts: { templateFile: string; options: JsonValue; force?: boolean }): WritableHandle;Schedules a file-creation directive and returns a WritableHandle for chaining. path and
template are both template strings, rendered independently against the same options;
the full template mini-language (delimiters, the 7 pipes, loops, conditionals) lives in
the templates guide — this page only covers the call itself. The
generic S overload narrows options to a schema’s keys at the type level only; the
runtime behavior is identical to the plain overload.
import { create } from "@pbuilder/sdk/commons";
create("src/index.ts", { template: "export const version = '{= .version =}';", options: { version: "1.0.0" },});The third overload swaps the inline template string for templateFile — a package-local
path (resolved against the run’s packageDir), read at emission time; its content becomes
the same template field the engine renders:
// templateFile overload — reads the template from disk instead of inlining itcreate("src/index.ts", { templateFile: "index.ts.template", options: { version: "1.0.0" },});Always pass options: {}, even when the template has no tokens. The type requires it —
and in untyped call sites (plain JS, any) omitting it puts undefined in the directive
batch, rejecting the write as unrepresentable.
Edge and error semantics:
- An existing target path rejects unless
{ force: true }is passed (fail-closed on overwrite) —AuthoringErrorwithverb: "create",reason: "path-collision". templateFileis only usable inside a run started withpackageDir— there is no resolution anchor otherwise (reason: "invalid-input", never a silent cwd fallback). The CLI passespackageDirautomatically; in tests you pass it yourself (see Testing).- A
templateFilethat is binary (a null byte or invalid UTF-8 anywhere in the file) or larger than the 4 MiB inline-render limit fails loud withreason: "invalid-input"— it never silently falls back to a by-reference copy. - A
templateFilethat is missing, is not a regular file, or can’t be read surfacesreason: "source-not-found" | "source-not-regular-file" | "source-unreadable"— the same three reasonscopyInandscaffoldshare for their own package-local reads. A literal../segment or an absolutetemplateFilepath rejectsreason: "invalid-input"instead, before any read (see the package-local rule below).
replaceContent
Section titled “replaceContent”function replaceContent(path: string, content: string): WritableHandle;Schedules an in-place, wholesale content replacement for an existing file — content is
a raw string, not a template. A rejected run (the target does not exist, reason: "path-not-found") throws AuthoringError.
import { replaceContent } from "@pbuilder/sdk/commons";
replaceContent("src/config.json", '{ "version": "2.0.0" }');A rejected .replaceContent() reports verb: "modify" on its AuthoringError, not
"replaceContent" — it lowers to the same wire mutation as a dialect handle’s .modify(fn)
escape hatch (see the Modify guide). This is
deliberate, not a stale rename — details in the
error handling guide.
remove
Section titled “remove”function remove(path: string): void;Schedules a file deletion. Idempotent: removing an absent file is not an error — in practice
remove never rejects.
import { remove } from "@pbuilder/sdk/commons";
remove("src/legacy.ts");rename
Section titled “rename”function rename(path: string, newName: string, opts?: { force?: boolean }): WritableHandle;Schedules a basename-only rename, returning a handle for the new path (the directory is
unchanged — only the last path segment is replaced). Renaming onto an existing path is
rejected unless { force: true } is passed — reason: "path-collision".
import { rename } from "@pbuilder/sdk/commons";
rename("src/foo.ts", "bar.ts");function move(path: string, toDir: string, opts?: { force?: boolean }): WritableHandle;Schedules a move to a different directory, returning a handle for the new location. Moving
onto an existing destination is rejected unless { force: true } (reason: "path-collision"); a move whose destination equals its source is a no-op, never a collision.
import { move } from "@pbuilder/sdk/commons";
move("src/utils/helper.ts", "src/shared");function copy(from: string, to: string, opts?: { force?: boolean }): WritableHandle;Schedules a tree-to-tree copy, returning a handle you can chain further edits onto — the
fake test harness stages its content, so a chained .read() on the returned handle sees it.
Copying onto an existing destination is rejected unless { force: true } (reason: "path-collision").
import { copy } from "@pbuilder/sdk/commons";
copy("src/template.ts", "src/generated/output.ts");copyIn
Section titled “copyIn”function copyIn(from: string, to: string, opts?: { force?: boolean }): void;Copies ONE package-local file (from, resolved against the run’s packageDir) into the
tree, always by-reference — never classified or rendered, even when the source is plain
text containing template-like sequences. This is copy’s sibling for package-local sources;
contrast with create({ templateFile }), which explicitly renders a package-local
source instead.
import { copyIn } from "@pbuilder/sdk/commons";
copyIn("assets/logo.svg", "src/generated/logo.svg");Unlike copy, copyIn returns void, not a WritableHandle: a by-reference destination’s
bytes exist only after the engine applies the directive — the fake test harness never
materializes them, so a handle chaining over tree content would lie about content that was
never staged. This asymmetry with copy (which does stage tree-to-tree content the fake
can chain over) is deliberate.
Edge and error semantics:
from/toare mandatory — a missing one rejectsreason: "invalid-input"before any emission.- Only usable inside a run started with
packageDir— otherwisereason: "invalid-input", never a cwd fallback. - The source is screened lexically (
..//absolute rejectsreason: "invalid-input"pre-read), then validated for existence and regular-file-ness, surfacingreason: "source-not-found" | "source-not-regular-file" | "source-unreadable"— the same three reasonscreate({ templateFile })andscaffoldshare. - A destination collision without
{ force: true }rejectsreason: "path-collision",verb: "copyIn"— the author never calledcopy, but the label still names the actual offending call.
modify — the structural mutation
Section titled “modify — the structural mutation”The eighth mutation is the one that doesn’t come from @pbuilder/sdk/commons — and that’s
by design. Every verb above treats a file as text; modify is a structural edit,
and to edit a file structurally you first have to understand it, which means parsing it
into an AST. That understanding lives in the dialects:
import * as ts from "@pbuilder/sdk/typescript";
await ts.find("src/index.ts") .addImport("readFileSync", "node:fs") // named structural op .modify((ast) => { // escape hatch for anything else /* full ts-morph surface */ });Whichever way you author it — a named op like addImport or the .modify() escape hatch —
the whole chain coalesces and reaches the engine as one modify instruction carrying the
final file content. The engine never sees the AST. replaceContent lowers to the same
wire mutation, which is why its errors report verb: "modify".
See Modify for the full authoring surface.
Package-local sources
Section titled “Package-local sources”create({ templateFile }), copyIn, and scaffold each read a source that lives on the
package’s own disk, resolved against the run’s packageDir. The author rule:
the SDK rejects lexical
../or absolute source paths, always; everything a schematic reads lives inside its package.
The SDK screens the literal path shape (no .. segment, no absolute form) before touching
disk — symlinks are followed without target verification, a deliberate, documented residual
covered in the SDK’s SECURITY.md, not an oversight.
Reading files: find(path).read()
Section titled “Reading files: find(path).read()”find(path) locates an existing file and returns a handle for reading or removing it.
read() resolves to exactly one of three states — never a truthiness check:
absent— the path does not exist.read()resolvesundefined.empty— the file exists but its content is the exact empty string"".present— any other string, including falsy-looking ones like"0"or"false".
import { find, create, replaceContent } from "@pbuilder/sdk/commons";
const content = await find("src/config.ts").read();if (content === undefined) { create("src/config.ts", { template, options });} else if (content === "") { replaceContent("src/config.ts", seedContent);} else { replaceContent("src/config.ts", patch(content));}Branch on the three outcomes with strict === undefined / === "" comparisons — never
if (!content), which silently merges undefined, "", "0", and "false".
classifyContent() (also exported from @pbuilder/sdk/commons) names the trichotomy
directly, for an exhaustive switch instead of manual comparisons:
import { classifyContent } from "@pbuilder/sdk/commons";
switch (classifyContent(content)) { case "absent": // ... break; case "empty": // ... break; case "present": // ... break;}Reads come back from the engine’s staging tree, not from disk — so reading a path you created earlier in the same run sees the staged content. That read-back is what turns a schematic into a conversation: idempotent, content-aware edits instead of blind overwrites.
Check before mutating
Section titled “Check before mutating”Factories re-run against already-generated projects, so mutations must be idempotent: check whether your marker, import, or entry is already present before inserting it. The verbs are built for this discipline —
createis fail-closed on an existing path (path-collision): a re-run never silently clobbers earlier output. Reach for{ force: true }only when overwriting is the intent.find().read()tells you exactly what state the tree is in, so you can branch: create when absent, seed when empty, patch when present — the trichotomy example above is the canonical shape.removeis already idempotent; a re-run that removes an already-removed file is a no-op.
A production factory appending to a list, for example, would also skip the append when the
entry is already listed — read the content, check for the entry, and only then
replaceContent. The testing guide shows how to assert
idempotence with seeded trees.
Next steps
Section titled “Next steps”- Templates — the
{= =}mini-languagecreaterenders with. - Scaffolding — mirror a whole folder of templates with
scaffold. - Dry-run — preview a factory’s planned changes before anything commits.
- Error handling — what
AuthoringErrorlooks like and how to assert against it.