Skip to content

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.)

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.

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 it
create("src/index.ts", {
templateFile: "index.ts.template",
options: { version: "1.0.0" },
});

Edge and error semantics:

  • An existing target path rejects unless { force: true } is passed (fail-closed on overwrite) — AuthoringError with verb: "create", reason: "path-collision".
  • templateFile is only usable inside a run started with packageDir — there is no resolution anchor otherwise (reason: "invalid-input", never a silent cwd fallback). The CLI passes packageDir automatically; in tests you pass it yourself (see Testing).
  • A templateFile that is binary (a null byte or invalid UTF-8 anywhere in the file) or larger than the 4 MiB inline-render limit fails loud with reason: "invalid-input" — it never silently falls back to a by-reference copy.
  • A templateFile that is missing, is not a regular file, or can’t be read surfaces reason: "source-not-found" | "source-not-regular-file" | "source-unreadable" — the same three reasons copyIn and scaffold share for their own package-local reads. A literal ../ segment or an absolute templateFile path rejects reason: "invalid-input" instead, before any read (see the package-local rule below).
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" }');
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");
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");
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");

Edge and error semantics:

  • from/to are mandatory — a missing one rejects reason: "invalid-input" before any emission.
  • Only usable inside a run started with packageDir — otherwise reason: "invalid-input", never a cwd fallback.
  • The source is screened lexically (..//absolute rejects reason: "invalid-input" pre-read), then validated for existence and regular-file-ness, surfacing reason: "source-not-found" | "source-not-regular-file" | "source-unreadable" — the same three reasons create({ templateFile }) and scaffold share.
  • A destination collision without { force: true } rejects reason: "path-collision", verb: "copyIn" — the author never called copy, but the label still names the actual offending call.

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.

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.

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() resolves undefined.
  • 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));
}

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.

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 —

  • create is 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.
  • remove is 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.

  • Templates — the {= =} mini-language create renders with.
  • Scaffolding — mirror a whole folder of templates with scaffold.
  • Dry-run — preview a factory’s planned changes before anything commits.
  • Error handling — what AuthoringError looks like and how to assert against it.