This is the full developer documentation for Project Builder # Code generation you can trust > Project Builder turns code generation into a deterministic program. Write a schematic once (typed, testable, idempotent), and every run produces exactly the same result. deterministic by design ```sh builder execute default:hello --name=payments ``` ```txt ~ services.txt ~ src/services/payments.ts ✓ done — 2 modified ``` Run it again with `–name=orders` and `services.txt` grows by one line. Re-run the *same* name and it fails closed, so nothing is written twice. The same schematic with the same input always produces the same bytes. # Join the beta > Apply to test Project Builder and send feedback directly from your MCP client. Project Builder’s beta program is for people who want to test real project-generation workflows and report problems before a public release. Access is tied to a verified GitHub identity and is granted manually. ## Apply [Section titled “Apply”](#apply) 1. Open the beta application page below. 2. Select **Continue with GitHub** and authorize the GitHub account you will use with your MCP client. 3. After GitHub returns you to Project Builder, select **Join the review list**. 4. Wait for manual review. Applying does not grant immediate MCP access. 5. When approved, you will receive an email from `beta@pbuilder.dev` confirming that your GitHub identity can connect. [Apply to become a beta tester](https://project-builder-schematics.github.io/feedback-dashboard/?mode=apply) ## MCP server URL [Section titled “MCP server URL”](#mcp-server-url) Use this Streamable HTTP endpoint with a supported MCP client: ```text https://bbivrybsyxpmkstomccd.supabase.co/functions/v1/project-builder-mcp ``` ## Connect from Codex [Section titled “Connect from Codex”](#connect-from-codex) Add the remote server: ```sh codex mcp add project-builder-feedback \ --url https://bbivrybsyxpmkstomccd.supabase.co/functions/v1/project-builder-mcp ``` Start GitHub authentication: ```sh codex mcp login project-builder-feedback ``` Complete the browser flow with the same GitHub account used in your application. Restart Codex if the newly added tools do not appear in an existing session. ## Connect from Claude Code [Section titled “Connect from Claude Code”](#connect-from-claude-code) Add the remote HTTP server: ```sh claude mcp add --transport http project-builder-feedback \ https://bbivrybsyxpmkstomccd.supabase.co/functions/v1/project-builder-mcp ``` Open Claude Code, run `/mcp`, select `project-builder-feedback`, and complete authentication in the browser with your approved GitHub account. ## Send feedback [Section titled “Send feedback”](#send-feedback) Once connected, ask your MCP client to use the `report_issue` tool. A useful report includes: * a short, specific title; * what happened and what you expected; * exact reproduction steps; * the Project Builder version and operating system; * whether the problem blocks your work or has a workaround. Example: ```text Report this Project Builder issue through the feedback MCP: the generator stops after the first prompt on macOS 15 using version 0.14.2-beta.3. It should create the project and exit successfully. Reproduce it by running ... ``` The tool returns a report reference. If evidence would help, ask the client to create an attachment upload link, then open that temporary link to add supported images or videos. Upload links are private capabilities: do not share them. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) * **Access denied:** confirm that the application was approved and that the MCP client authenticated with the same GitHub account. * **Application still pending:** applications are reviewed manually; wait for the approval email before connecting. * **Authentication opens the wrong account:** clear the MCP server’s saved authentication and repeat the browser flow with the approved GitHub account. * **Tools do not appear:** restart the MCP client after adding and authenticating the server. * **An upload link expired:** ask the MCP client to create a new attachment upload link. Never share approval emails, OAuth links, invitation codes, access tokens, or attachment upload links. # How it works > The SDK–engine conversation, instruction records, the AST story, and the language-agnostic bridge that make deterministic file mutation possible. Mutating files reliably is the complicated part of code generation. Project Builder simplifies it by splitting the work across three parts with clearly separated responsibilities, two of which hold a conversation. ## The SDK–engine conversation [Section titled “The SDK–engine conversation”](#the-sdkengine-conversation) ![Sequence diagram of the SDK–engine conversation: the engine spawns the SDK runner with the factory and the user’s input and sends a single ready message; the factory chains mutations that buffer as directives; on a read the buffer flushes to the engine as an ir.emit batch applied eagerly to the staging tree, followed by tree.read and its response; when the factory returns, a final flush and ir.commit close the run and the engine’s Commit writes the staged result to disk.](/_astro/sdk-engine-flow.Cwkc8xIX_ZnFzPE.svg) ### SDK — instruction records and developer experience [Section titled “SDK — instruction records and developer experience”](#sdk--instruction-records-and-developer-experience) The **SDK** (`@pbuilder/sdk`) has two responsibilities. First, it produces **IR (instruction records)**: JSON directives that describe *what should happen* (“create this file with this content”, “move this one there”). Your factory never writes to your project; it emits intent. Second, it owns the **developer experience**: typed [mutation verbs](/guides/mutation-verbs/), [templates](/guides/templates/), [dialects](/guides/modify/), and the testing harness. Together they make file mutation, historically a painful job, feel simple. The SDK and the engine hold a **conversation**. Directives don’t cross one by one: as your factory chains mutations they buffer inside the SDK, and the buffer **flushes** to the engine as a single batch at exactly two moments: when the factory finishes, and the moment you read a file. Flushing on read means a read always sees your own pending writes first. ### Engine — applying the records [Section titled “Engine — applying the records”](#engine--applying-the-records) The **engine** receives those instruction records and applies them, with several mechanisms for doing so safely: everything lands in a staging tree while your factory runs, and only after the factory returns successfully is the staged result written to disk. The engine is the only component that writes, and a failed run writes nothing. The engine is **AST-blind and author-API-blind**. It doesn’t know what a verb or a dialect is; code edits arrive as final file content, ready to apply. Its one authoring job is rendering [`{= =}` templates](/guides/templates/) against your inputs. Because of that, the SDK surface can grow richer without the execution contract changing. ### CLI — the agent-facing surface [Section titled “CLI — the agent-facing surface”](#cli--the-agent-facing-surface) The **`builder` CLI** is the front door: `init`, `new`, `execute`. It validates your input, resolves the schematic, and starts the engine, which owns the run from there. The CLI is deliberately designed for AI agents to drive: output comes back as structured JSON, so an agent can run a schematic and parse the result like any other API. The CLI works with two anchors: the **manifest root**, which every read (`project-builder.json`, collections, factories, schemas, templates) resolves against, and the **working directory**, which every write lands in. By default they are the same directory; `--manifest` separates them (see [External collections](/guides/external-collections/)). ## The AST story [Section titled “The AST story”](#the-ast-story) As an extreme but useful simplification, **writing software, day to day, is mutating files.** And a file is just text; the extension is what gives that text meaning. Seen that way, most mutations are simple: create, replace, move, copy treat the file as text and never need to know what it means. The exception is `modify`. To change a file programmatically you first have to **understand** it, and a program understands a file through an **AST** (abstract syntax tree). Every file type has its own parser library, and the ASTs they produce follow no shared convention. Left alone, every schematic author would have to pick libraries, install them, and learn each one’s tree shape. The SDK solves this by **centralizing the ASTs**: it builds on those libraries and exposes them as ready-to-use dialects: TypeScript, React, JSON, YAML and CSS today, plus NestJS and Angular on top of TypeScript. HTML and dialects for Vue and Svelte are planned. You install nothing extra; they’re already part of the SDK. When a custom edit needs the library itself, each dialect exports it as `astLibrary` (see [AST libraries](/reference/ast-libraries/)). The boundary stays clean: **ASTs belong to the SDK.** By the time an AST edit reaches the engine, it has already been lowered to a plain, ready-to-apply record; the engine never parses code. ## Under the hood: an engine that speaks wire, not language [Section titled “Under the hood: an engine that speaks wire, not language”](#under-the-hood-an-engine-that-speaks-wire-not-language) The SDK is TypeScript; the engine is Go. They talk through a deliberately thin bridge: the engine runs a **Bun sidecar** through an adapter, and that sidecar starts the SDK’s **runner**, the entry point that loads your factory and opens the conversation. From there, everything that crosses between them is **framed JSON** on the wire: `ir.emit`, `tree.read`, `ir.commit`, `ir.discard`. ![Diagram of the language bridge: the builder CLI starts the Go engine, which talks framed JSON over stdin/stdout through a Bun adapter to the TypeScript SDK runner where the factory and ASTs live; ghosted boxes show future SDKs in Python or any language plugging into the same wire with a new adapter, and a bottom panel notes that the SDK is self-contained so tests run with no CLI, no engine, and no disk.](/_astro/sdk-engine-bridge.u4ZY-QeI_2vOGFs.svg) Two properties fall out of this design: * **The engine is language-agnostic.** It speaks only the wire protocol. An SDK in Python, Rust, or any other language only needs a new adapter; the engine, the staging tree, and the safety guarantees stay exactly the same. * **The SDK is self-contained.** Everything a factory produces is IR, so the testing harness can stand in for the entire engine side and run factories completely in memory, with no CLI, engine, or disk. That’s why schematics are testable like any other code. ### For integrators: how the runner receives input [Section titled “For integrators: how the runner receives input”](#for-integrators-how-the-runner-receives-input) This matters only if you build an engine or adapter that spawns the SDK runner yourself. `builder execute` handles it for you, and there is no `--input-file` flag on `builder`. The runner takes the schematic’s input from **exactly one** of `--input ` or `--input-file `. An input file must be a **readable regular file** containing valid JSON, no larger than 10 MiB (10485760 bytes). Absolute and relative paths both work, and a symbolic link is followed when its target is a regular file, even one outside the working directory. The runner checks the input **before** it expects the host’s greeting and before it imports the factory. When the input is rejected, the runner exits with code `1` and one line on stderr: | Input | Diagnostic | | -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | A directory, named pipe or other non-regular file; a missing file; a broken link | `pbuilder-runner: --input-file could not be read — is not a readable file` | | A file over the cap | `pbuilder-runner: --input-file exceeds the 10485760-byte cap` | | Invalid JSON | `pbuilder-runner: --input-file is not valid JSON (line L, column C)` | | Both `--input` and `--input-file` | `pbuilder-runner: --input and --input-file are mutually exclusive — pass exactly one` | | Neither | `pbuilder-runner: exactly one of --input or --input-file is required` | None of these is a factory or handshake problem: materialize the JSON into a regular file (not a pipe or a directory) and, for a link, check its target. The runner’s input handling is defined in the SDK source, [`src/transport/runner.ts`](https://github.com/Project-Builder-Schematics/project-builder-sdk/blob/main/src/transport/runner.ts). The runner is not a standalone generator: without a host speaking the protocol on stdin/stdout, it stops at the greeting. # Installation > Install the builder CLI with Homebrew or a tarball, add Bun as the schematics runtime, and get the SDK into your project. Two things need to be on your machine: the **`builder` CLI** (which ships the engine) and **[Bun](https://bun.sh)**, the runtime schematics execute in. The SDK itself is added per project, usually automatically. Run `builder --version` to check your installed CLI version. ## macOS / Linux [Section titled “macOS / Linux”](#macos--linux) With [Homebrew](https://brew.sh) installed, grab the CLI (the `builder` binary) from the tap: ```sh brew install project-builder-schematics/tap/pbuilder ``` Linux / WSL2 This needs a recent Homebrew. The cask install is verified on Homebrew 6.x; older versions refuse casks on Linux. No Homebrew at all? Download the per-platform tarball from the [tap’s releases](https://github.com/Project-Builder-Schematics/homebrew-tap/releases) (`pbuilder__linux_amd64.tar.gz` and friends) and put `builder` on your `PATH`. Schematics execute through a [Bun](https://bun.sh) sidecar, so Bun must be installed too: ```sh brew install oven-sh/bun/bun # or: curl -fsSL https://bun.sh/install | bash ``` Note Bun is only the schematics’ runtime. Your project itself keeps whatever package manager and tooling it already uses (npm, pnpm, yarn, …). ## Windows [Section titled “Windows”](#windows) Not yet supported Windows builds of the CLI are on the roadmap; in the meantime the supported path on Windows is WSL2 (Ubuntu), using the Linux instructions above. ## The SDK itself [Section titled “The SDK itself”](#the-sdk-itself) You normally don’t install it by hand: `builder init` adds `@pbuilder/sdk` to your project’s `devDependencies` automatically. To add it manually, use whichever package manager your project already uses: ```sh npm install -D @pbuilder/sdk # or: pnpm add -D / yarn add -D / bun add -d ``` Just trying Project Builder out? You can run it without declaring the SDK in `package.json`. See [Evaluate without adopting the SDK](/guides/evaluate-without-sdk/). With the CLI and Bun in place, you’re ready for [your first schematic](/getting-started/your-first-schematic/). # What is Project Builder? > Deterministic code generation designed to complement AI, for consistent software, saved time, and patterns that grow with your team. Project Builder gives developers, and the AI agents working alongside them, deterministic code generation. A prompt produces something different every run; a **schematic**, a typed and testable file-mutation program, produces the same files, byte for byte, every time. You (or your agent) encode the change once, and from then on you run that program instead of reviewing generated output. ## Built to complement AI [Section titled “Built to complement AI”](#built-to-complement-ai) As software craftsmen we want to build quality software, and quality starts with code that is predictable and repeatable. AI is a capable collaborator, but its output is *variable*: the same request produces different code every run. Project Builder is designed to complement that variability. Let the AI decide **what** to build, and let a schematic make the **how** deterministic. That determinism keeps generated code consistent and saves time and money: * Every service, component, or module generated by a schematic follows the same structure and conventions, so what the AI produces on Friday does not drift from what it produced on Monday. * A change encoded once runs in milliseconds on every run. You spend no tokens regenerating it and no review cycles re-validating output that should never have changed. Schematics also grow with your codebase and your team. Every pattern you encode becomes a reusable, versioned building block that new teammates, human or agent, can run on day one. ## What is a schematic? [Section titled “What is a schematic?”](#what-is-a-schematic) A schematic is a small package that lives in your repository. It has three files: * `schema.json`: the schematic’s typed inputs, the contract with whoever runs it. * `schema.generated.ts`: an `Input` type generated from the schema, so your code is typed against the contract rather than a hand-written shape. * `factory.ts`: your authoring logic, a function that receives the typed input and schedules file mutations such as creating files, editing content, and scaffolding whole folders of templates. Once registered, you run it with `builder execute :`, passing inputs as CLI flags. Because a factory is just a typed function, it can also be executed entirely in memory by the testing harness, without the CLI or the disk. That is what makes schematics testable like any other code. See [Your first schematic](/getting-started/your-first-schematic/) for the end-to-end walkthrough. The golden rule: never improvise a schematic A schematic is an **automation of code that already works**. Have the code running and tested first, *then* encode it as a schematic. Premature automation spends time and resources on something you may only ever run once: extract a schematic when a pattern has proven itself, never before. The machinery (the SDK–engine conversation, instruction records, and the AST story) has [a page of its own](/getting-started/how-it-works/). ## Design principles you’ll feel as a user [Section titled “Design principles you’ll feel as a user”](#design-principles-youll-feel-as-a-user) * **Idempotency.** Factories re-run against already-generated projects, so mutations are written to be safe on repeat: read the tree first, then create or update, and skip the edit when your marker, import, or entry is already present. * **Fail-closed.** Verbs that write to a new path (`create`, `rename`, `move`, `copy`, `copyIn`, `scaffold`) reject on a collision with an existing path; overwriting is always a deliberate opt-in via `force: true`. * **All-or-nothing runs.** Nothing touches disk while your factory runs. If it throws before it returns, nothing is written at all, and your tree is left exactly as it was. ## Where next [Section titled “Where next”](#where-next) How it works The SDK–engine conversation, flush, and the language-agnostic bridge: [How it works](/getting-started/how-it-works/). Install it Get the `builder` CLI and Bun set up in a couple of commands: [Installation](/getting-started/installation/). Build your first schematic From `builder init` to a running, idempotent generator: [Your first schematic](/getting-started/your-first-schematic/). Evaluating Project Builder in an existing repository? You can try it without adding the SDK to `package.json`. See [Evaluate without adopting the SDK](/guides/evaluate-without-sdk/). # Testing schematics > Run a factory in memory with the SDK's testing harness and assert on the files it writes, with no CLI, engine, or disk. Testing utilities, not a test runner `@pbuilder/sdk/testing` provides utilities for testing schematics with your test runner. Bun is not required for the in-memory factory example below: it runs on Node with Vitest, Jest, or Rstest. Run your factory through `runFactoryForTest`, then assert on the resulting tree. This needs no CLI or engine process, and no generated files land on disk. ## A first test [Section titled “A first test”](#a-first-test) Start in a separate directory so this example does not change your application’s test configuration. Use Node **26.8.1** and npm **11.19.0**, the versions used to verify it. Create this `package.json`, then install the SDK and Vitest: package.json ```json { "private": true, "type": "module" } ``` ```sh npm install --save-dev --save-exact @pbuilder/sdk@0.2.4 vitest@5.0.0 ``` Use a JavaScript ESM factory to keep the example independent of TypeScript transforms: factory.js ```js import { create } from '@pbuilder/sdk/commons'; export function greetingFactory(input) { create('src/greeting.txt', { template: `Hello, ${input.name}!\n`, options: {}, }); } ``` Create a `tests` directory and add: tests/vitest.test.js ```js import process from 'node:process'; import { expect, test } from 'vitest'; import { runFactoryForTest } from '@pbuilder/sdk/testing'; import { greetingFactory } from '../factory.js'; test('commits a greeting through the SDK harness on Node without Bun', async () => { expect(process.versions.node).toBeDefined(); expect(process.versions.bun).toBeUndefined(); const result = await runFactoryForTest(greetingFactory, { name: 'Ada' }); expect(result.error).toBeUndefined(); expect(result.tree.get('src/greeting.txt')).toBe('Hello, Ada!\n'); }); ``` vitest.config.js ```js import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { environment: 'node', include: ['tests/vitest.test.js'] }, }); ``` ```sh node node_modules/vitest/vitest.mjs run ``` Expected result: **one passing test**. The greeting exists in the committed in-memory tree, not as `src/greeting.txt` on disk. JavaScript interpolation produces the text; this test does not exercise SDK template rendering. ## Use Jest or Rstest instead [Section titled “Use Jest or Rstest instead”](#use-jest-or-rstest-instead) Keep the same `package.json` and `factory.js`. Copy the test above to the path for your chosen runner and replace **only** its `expect, test` import; keep the other imports and assertions unchanged. Install only the runner you choose. Each configuration selects its own test file, so the three examples can also coexist. ### Jest [Section titled “Jest”](#jest) ```sh npm install --save-dev --save-exact @pbuilder/sdk@0.2.4 jest@30.5.1 ``` In `tests/jest.test.js`, replace the Vitest import with: ```js import { expect, test } from '@jest/globals'; ``` jest.config.js ```js export default { testEnvironment: 'node', testMatch: ['/tests/jest.test.js'], transform: {}, }; ``` ```sh node --experimental-vm-modules node_modules/jest/bin/jest.js --runInBand ``` Expected result: **one passing test**. This uses Jest’s native ESM mode with transforms disabled; Node emits an experimental VM Modules warning. ### Rstest [Section titled “Rstest”](#rstest) ```sh npm install --save-dev --save-exact @pbuilder/sdk@0.2.4 @rstest/core@0.11.12 ``` In `tests/rstest.test.js`, replace the Vitest import with: ```js import { expect, test } from '@rstest/core'; ``` rstest.config.js ```js import { defineConfig } from '@rstest/core'; export default defineConfig({ testEnvironment: 'node', include: ['tests/rstest.test.js'], }); ``` ```sh node node_modules/@rstest/core/bin/rstest.js run ``` Expected result: **one passing test**, using the same factory and assertions. ### Scope of the verified example [Section titled “Scope of the verified example”](#scope-of-the-verified-example) These versions passed the inline `create` case on Node 26.8.1 on macOS ARM64. The SDK declares Node `>=25.9.0` and Bun `1.3.14` in its engines; this result does not change those requirements or establish official Node-only support for every SDK feature. Schema validation, filesystem scaffolding, template rendering, and TypeScript type checking are not covered by this example. Runner references: [Vitest configuration](https://vitest.dev/config/), [Jest ESM](https://jestjs.io/docs/30.0/ecmascript-modules), and [Rstest configuration](https://rstest.rs/guide/basic/configure-rstest). ## Read the result tree [Section titled “Read the result tree”](#read-the-result-tree) `result.tree` is typed as `ReadonlyMap` and is a `Map` at runtime, not a plain object. Use `.get(path)` to read content (`undefined` when absent), `.has(path)` to check membership, `[...result.tree.keys()]` to list paths, and `.size` to count entries. Do not use `result.tree[path]` to read files. ## Seed files and idempotence [Section titled “Seed files and idempotence”](#seed-files-and-idempotence) The `seed` option is a plain object (`Record`); the returned `result.tree` is a Map containing **committed writes only**, not a full workspace snapshot. Seed files are readable by the factory, but untouched seed paths are absent from the returned Map. A write to a seeded path appears when committed: ```ts import { test, expect } from "vitest"; import { runFactoryForTest } from "@pbuilder/sdk/testing"; import { find, replaceContent } from "@pbuilder/sdk/commons"; test("a seeded file is readable; only the write is committed", async () => { const run = async (input: { name: string }) => { const existing = await find("services.txt").read(); replaceContent("services.txt", `${existing}\n${input.name}`); }; const seed = { "services.txt": "payments", "untouched.txt": "keep" }; const result = await runFactoryForTest(run, { name: "orders" }, { seed }); expect(result.error).toBeUndefined(); expect(result.tree.get("services.txt")).toEqual("payments\norders"); expect(result.tree.has("untouched.txt")).toBe(false); }); ``` ## `packageDir` — anchoring package-local verbs [Section titled “packageDir — anchoring package-local verbs”](#packagedir--anchoring-package-local-verbs) The options bag’s other field, `packageDir` (the schematic package’s absolute directory), anchors the package-local verbs (`scaffold`, `copyIn`, `create({ templateFile })`) and opts the run into schema-derived input validation against the adjacent `schema.json`. Without it those verbs have nothing to resolve against. (When the CLI runs your schematic, it passes the package location automatically, so this is only a testing concern. See [Scaffolding](/guides/scaffolding/) for the verbs themselves.) ## What the harness does — and doesn’t — fake [Section titled “What the harness does — and doesn’t — fake”](#what-the-harness-does--and-doesnt--fake) Templates are stored **verbatim** in the test tree. Rendering happens in the engine at `builder execute` time, and the harness doesn’t fake it. So asserting on a templated `create` means asserting on the raw `{= .name =}` text, not rendered output. Caution `@pbuilder/sdk/testing` ships `0.x`, semver-exempt, until real use validates the result shape. ## Bun as an alternative [Section titled “Bun as an alternative”](#bun-as-an-alternative) You can also use Bun’s test runner with the harness. Import `test` and `expect` from `bun:test` in your Bun tests and run `bun test` with their path. The Node runtime assertions above are specific to the Node smoke test; omit them when running on Bun. ### Editor errors on `bun:test`? [Section titled “Editor errors on bun:test?”](#editor-errors-on-buntest) `bun test` strips types rather than checking them, so it runs fine either way, but your editor needs a few tsconfig settings to resolve the imports. Add `typescript` and `@types/bun` as dev dependencies, then make sure your `tsconfig.json` has: * `moduleResolution: "NodeNext"` (or `"bundler"`): TypeScript’s default legacy resolution cannot read a package’s `exports` map, which is the only route to `@pbuilder/sdk`’s subpaths (`./testing`, `./commons`, and friends). Without this, your editor reports `Cannot find module '@pbuilder/sdk/commons'` even though the import is correct. * `allowImportingTsExtensions: true`: factories import the generated types with an explicit `.ts` extension (`./schema.generated.ts`), which TypeScript rejects by default. * If you restrict ambient types with `types`, include `"bun"` alongside any existing entries so the editor can resolve `bun:test` from `@types/bun`. # Your first schematic > Go from an empty workspace to a running, idempotent code generator in six commands. Every step below is runnable exactly as written. You’ll initialise a workspace, scaffold a schematic, declare its typed inputs, write the factory, and execute it twice to see idempotency at work. [Install the CLI and Bun](/getting-started/installation/) first if you haven’t. 1. **Initialise a workspace** (inside any project, new or existing): ```sh builder init ``` This creates `project-builder.json` (the workspace config), a `schematics/` folder, and an AI skill under `.claude/skills/pbuilder/`: a command reference your coding agent picks up automatically. It also installs `@pbuilder/sdk` as a dev dependency, creating a `package.json` first if the folder doesn’t have one. 2. **Scaffold a schematic:** ```sh builder new schematic hello ``` You get a registered, ready-to-edit package: ```txt schematics/hello/ ├── schema.json # typed inputs — the contract with the user ├── schema.generated.ts # generated from schema.json — never edit └── factory.ts # your authoring logic ``` `project-builder.json` now lists it under `collections.default.hello`. 3. **Declare the inputs.** Replace the generated `schema.json`’s contents with the following; every property needs a `label` (codegen fails without it): schematics/hello/schema.json ```json { "properties": { "name": { "type": "string", "label": "Service name", "required": true } } } ``` Property types are `string`, `number`, `boolean`, and `enum`; `default` and `description` are optional. An `enum` currently needs its values under **both** `choices` (read by codegen and the SDK) and `enum` (read by `builder execute`), with the same values: ```json { "type": "enum", "label": "Size", "choices": ["sm", "md"], "enum": ["sm", "md"] } ``` 4. **Regenerate the input types:** ```sh bunx pbuilder-codegen schematics/hello ``` This rewrites `schema.generated.ts` with an `Input` type derived from your schema, so your factory is typed against the schema, never against a hand-written shape. To regenerate every registered schematic at once, run `bunx pbuilder-codegen` with no arguments (see [Type generation](/guides/type-generation/)). 5. **Write the factory:** schematics/hello/factory.ts ```ts import { create, find, replaceContent } from "@pbuilder/sdk/commons"; import type { Input } from "./schema.generated.ts"; export default async (input: Input) => { // create a new file create(`src/services/${input.name}.ts`, { template: `export const serviceName = "${input.name}";`, options: {}, }); // content-aware edit: read the tree, then create or update const existing = await find("services.txt").read(); if (existing === undefined) { create("services.txt", { template: input.name, options: {} }); } else { replaceContent("services.txt", `${existing}\n${input.name}`); } }; ``` The engine invokes the module’s **default export**, which is the factory. This one builds its output strings in TypeScript; the [template language](/guides/templates/) is the declarative alternative. 6. **Execute it.** `default:hello` is `:` as registered in `project-builder.json`; inputs are passed as CLI flags: ```sh builder execute default:hello --name=payments ``` ```txt ~ services.txt ~ src/services/payments.ts ✓ done — 2 modified ``` (`~` marks a path the run wrote.) ## Run it again [Section titled “Run it again”](#run-it-again) Now run it again **with a different input**: ```sh builder execute default:hello --name=orders ``` `services.txt` grows by one line instead of being clobbered, because of the read-back loop from step 5. Re-running with the **same** name, on the other hand, rejects with `path-collision`: `create` is fail-closed on an existing path (pass `force: true` to overwrite deliberately), and since a failed run writes nothing, your tree is left exactly as it was. Tip Make mutations idempotent. Factories re-run against already-generated projects, so check whether your marker, import, or entry is already present before inserting it. The factory above shows the read-back mechanics; a production factory would also skip the append when the name is already listed. Note If your editor flags the `.ts`-extension import or `bun:test`, the cause is missing tsconfig settings (see [Testing schematics](/getting-started/testing/)); Bun itself runs fine without them. ## Two rules that save debugging time [Section titled “Two rules that save debugging time”](#two-rules-that-save-debugging-time) * **Always pass `options: {}` to `create`**, 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. * **`find().read()` is a trichotomy**: `undefined` means the file is absent, `""` means it exists and is empty. Branch on `=== undefined`, never `if (!content)`, which conflates the two. ## Next steps [Section titled “Next steps”](#next-steps) * **Test it** without the CLI or a running engine: [Testing schematics](/getting-started/testing/). * [Mutation verbs](/guides/mutation-verbs/): everything a factory can do to the tree. * [Templates](/guides/templates/): the declarative template language inside `create()`. * [Scaffolding](/guides/scaffolding/): mirror a whole folder of templates instead of writing one `create()` per file. * [Pending directives](/guides/pending-directives/): preview a factory’s planned changes before anything commits. * [Error handling](/guides/error-handling/): the structured `AuthoringError` contract of a rejected run. # Angular dialect > Add component imports, root providers, routes, NgModule entries, signals and injections in an Angular project with @pbuilder/sdk/angular, on top of every TypeScript dialect op. `@pbuilder/sdk/angular` edits the `.ts` files of an Angular project. Its handle has every op of `@pbuilder/sdk/typescript` plus ops for standalone components, the application’s root providers, routes, `@NgModule` metadata, and class members such as signals and injected dependencies. All ops chain on one handle and the file is written once; see [Modify](/guides/modify/#how-a-dialect-handle-works) for how a handle works. Each op finds its target from the code in the file, whatever the file is named, so the ops work with both the `app.ts` and the `app.component.ts` naming styles. ```ts import * as angular from "@pbuilder/sdk/angular"; export const run = async () => { await angular .find("src/app/app.config.ts") .addRootProvider({ expression: "provideHttpClient(withFetch())" }, [ { name: "provideHttpClient", from: "@angular/common/http" }, { name: "withFetch", from: "@angular/common/http" }, ]); }; ``` ```ts // before import { ApplicationConfig } from "@angular/core"; import { provideRouter } from "@angular/router"; import { routes } from "./app.routes"; export const appConfig: ApplicationConfig = { providers: [provideRouter(routes)], }; // after import { ApplicationConfig } from "@angular/core"; import { provideRouter } from "@angular/router"; import { routes } from "./app.routes"; import { provideHttpClient, withFetch } from "@angular/common/http"; export const appConfig: ApplicationConfig = { providers: [provideRouter(routes), provideHttpClient(withFetch())], }; ``` ## Inherited TypeScript ops [Section titled “Inherited TypeScript ops”](#inherited-typescript-ops) The handle carries every [TypeScript dialect](/guides/modify/) op: `addImport`, `addDefaultImport`, `addNamespaceImport`, `addTypeImport`, `addSideEffectImport`, `addReExport`, `addExportAll`, `addFunction`, `addVariable`, `addClass`, `removeImport`, `removeExport`, `removeExportAll`, `addArrayElement`, `addClassProperty` and `sortImports`. An op the TypeScript dialect gains later is available here too. ## Code and its imports [Section titled “Code and its imports”](#code-and-its-imports) The component, root-provider and NgModule ops take `(element, imports?)`. * `element` is an identifier such as `"RouterOutlet"`, or `{ expression }` for code such as `{ expression: "provideHttpClient(withFetch())" }`. An expression must parse as exactly one expression. Beyond that, the op writes it as given. * `imports` lists the `{ name, from }` named imports the element needs. Each one is added with the same rules as the TypeScript `addImport`: an existing import of `name` from `from` is reused, and a `name` already bound some other way in the file rejects. If the list already has the element, the op leaves it as it is. Elements are compared token by token, so spacing and comments do not count. ## Component ops [Section titled “Component ops”](#component-ops) | Op | Appends to | | ----------------------------------------- | ------------------------------------------- | | `addComponentImport(element, imports?)` | `imports` of the file’s only `@Component` | | `addComponentProvider(element, imports?)` | `providers` of the file’s only `@Component` | Both ops create the metadata object or the array when it is missing. ```ts await angular .find("src/app/app.ts") .addComponentImport("RouterOutlet", [{ name: "RouterOutlet", from: "@angular/router" }]); ``` ```ts // before import { Component } from "@angular/core"; @Component({ selector: "app-root", imports: [], templateUrl: "./app.html", }) export class App {} // after import { Component } from "@angular/core"; import { RouterOutlet } from "@angular/router"; @Component({ selector: "app-root", imports: [RouterOutlet], templateUrl: "./app.html", }) export class App {} ``` ## Root providers [Section titled “Root providers”](#root-providers) `addRootProvider(element, imports?)` appends to the application’s root `providers`. It looks for them in this order: 1. The `providers` of the file’s only top-level variable typed `ApplicationConfig`, by annotation (`const appConfig: ApplicationConfig = …`) or `satisfies`, whatever its name. 2. The `providers` of the config object passed to the file’s only `bootstrapApplication(...)` call. When the call has no config yet, the op creates one. ```ts // src/main.ts before import { bootstrapApplication } from "@angular/platform-browser"; import { App } from "./app/app"; bootstrapApplication(App); // after addRootProvider({ expression: "provideHttpClient()" }, [...]) import { bootstrapApplication } from "@angular/platform-browser"; import { App } from "./app/app"; import { provideHttpClient } from "@angular/common/http"; bootstrapApplication(App, { providers: [provideHttpClient()] }); ``` When `bootstrapApplication` receives a config by name, such as an `appConfig` imported from `./app/app.config`, the op rejects and its message names that module. Run the op on that file instead. ## Routes [Section titled “Routes”](#routes) | Op | Adds | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `addRoute({ path, component, from })` | An eager route. It imports `component` from `from`. | | `addLazyRoute({ path, loadComponent \| loadChildren, export? })` | A lazy route that loads the module named by `loadComponent` or `loadChildren` (exactly one of them). With `export`, the route reads that named export; without it, the module’s default export. No import is added. | Both ops add to the file’s only top-level variable typed `Routes` or `Route[]`, by annotation or `satisfies`. Angular uses the first route that matches, so a new route goes before a top-level `{ path: "**" }` route when there is one, and at the end otherwise. ```ts await angular .find("src/app/app.routes.ts") .addRoute({ path: "about", component: "About", from: "./about/about" }) .addLazyRoute({ path: "settings", loadComponent: "./settings/settings", export: "Settings" }) .addLazyRoute({ path: "admin", loadChildren: "./admin/admin.routes" }); ``` ```ts // before import { Routes } from "@angular/router"; import { Home } from "./home/home"; export const routes: Routes = [ { path: "", component: Home }, { path: "**", redirectTo: "" }, ]; // after import { Routes } from "@angular/router"; import { Home } from "./home/home"; import { About } from "./about/about"; export const routes: Routes = [ { path: "", component: Home }, { path: "about", component: About }, { path: "settings", loadComponent: () => import("./settings/settings").then((m) => m.Settings) }, { path: "admin", loadChildren: () => import("./admin/admin.routes") }, { path: "**", redirectTo: "" }, ]; ``` An equal route is left as it is. A different route with the same `path` rejects, so a schematic run twice with other inputs never adds a second one. ### Routes in an NgModule app [Section titled “Routes in an NgModule app”](#routes-in-an-ngmodule-app) In an NgModule routing file with no `Routes` variable, the ops add to the routes of the only `RouterModule.forRoot(...)` or `RouterModule.forChild(...)` call: its inline array, or a variable in the same file that it names. ```ts await angular .find("src/app/app-routing.module.ts") .addRoute({ path: "about", component: "About", from: "./about/about" }); ``` ```ts // before import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { Home } from "./home/home"; const routes = [ { path: "", component: Home }, { path: "**", redirectTo: "" }, ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) export class AppRoutingModule {} // after import { NgModule } from "@angular/core"; import { RouterModule, Routes } from "@angular/router"; import { Home } from "./home/home"; import { About } from "./about/about"; const routes = [ { path: "", component: Home }, { path: "about", component: About }, { path: "**", redirectTo: "" }, ]; @NgModule({ imports: [RouterModule.forRoot(routes)], exports: [RouterModule], }) export class AppRoutingModule {} ``` The ops match `RouterModule` by that name. An aliased import (`RouterModule as RM`) is not found, and the op rejects. ### Known limitation: route comparison [Section titled “Known limitation: route comparison”](#known-limitation-route-comparison) Routes are compared token by token, and the ops write double quotes. A route written as `{ path: 'about', component: About }` is a different route from the one `addRoute` writes, so adding that route again rejects because the `path` is taken. ## NgModule [Section titled “NgModule”](#ngmodule) Angular recommends standalone components for new code. These ops maintain applications still built with `@NgModule`. Each takes `(element, imports?)`, like the component ops, and appends to one array of the file’s only `@NgModule`, creating the metadata object or the array when missing. | Op | Appends to | | ---------------------------------------- | -------------------------------------------------------------------------------------------------- | | `addNgModuleImport(element, imports?)` | `imports`, such as other NgModules or `StoreModule.forRoot({})` | | `addDeclaration(element, imports?)` | `declarations`. Angular requires those components, directives and pipes to be `standalone: false`. | | `addNgModuleProvider(element, imports?)` | `providers` | | `addNgModuleExport(element, imports?)` | `exports` | | `addBootstrap(element, imports?)` | `bootstrap` | The names avoid `addImport`, `addProvider` and `addExport` because `addImport` is already the inherited TypeScript op that adds an `import` statement. ```ts await angular .find("src/app/app.module.ts") .addDeclaration("UserCard", [{ name: "UserCard", from: "./user-card" }]); ``` ```ts // before import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; import { App } from "./app"; @NgModule({ declarations: [App], imports: [BrowserModule], bootstrap: [App] }) export class AppModule {} // after import { NgModule } from "@angular/core"; import { BrowserModule } from "@angular/platform-browser"; import { App } from "./app"; import { UserCard } from "./user-card"; @NgModule({ declarations: [App, UserCard], imports: [BrowserModule], bootstrap: [App] }) export class AppModule {} ``` ## Signals and injection [Section titled “Signals and injection”](#signals-and-injection) These ops add a class property through the TypeScript `addClassProperty` and import the Angular function it calls from `@angular/core`. The property goes after the class’s last property, which keeps inputs, outputs and injected dependencies above the constructor and the methods, as the Angular style guide asks. The ops find the class by its decorator, so they take no class name. If the class already has the same property, the op does nothing. Another member with the same name rejects. | Op | Adds | Class | | ----------------------------------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | `addInjection(name, token, options?)` | `private readonly name = inject(token);` | `@Component`, `@Directive`, `@Pipe`, `@Injectable` or `@Service` | | `addInput(name, options?)` | `readonly name = input(initial);`, or `input.required()` with `required: true` | `@Component` or `@Directive` | | `addModel(name, options?)` | `readonly name = model(initial);`, for a two-way binding | `@Component` or `@Directive` | | `addOutput(name, options?)` | `readonly name = output();` | `@Component` or `@Directive` | | `addSignal(name, initial, options?)` | `protected readonly name = signal(initial);` | `@Component` or `@Directive` | | `addComputed(name, expression, options?)` | `protected readonly name = computed(() => expression);` | `@Component` or `@Directive` | The modifiers follow the Angular style guide: `input`, `model` and `output` properties are `readonly`, and `signal` and `computed` properties are `protected readonly` because the template reads them. The file must have exactly one class the op can target. The options: * `type` is the generic argument and must parse as exactly one type. * `initial` is the value and must parse as exactly one expression. `addInput` and `addModel` reject `required: true` together with `initial`. * `scope` replaces `private` in `addInjection` and `protected` in `addSignal` and `addComputed`. * `imports` lists the `{ name, from }` imports those need, as in [Code and its imports](#code-and-its-imports). * In `addInjection`, `token` is an identifier or `{ expression }`. * In `addComputed`, an object literal expression is wrapped in parentheses so the arrow function returns it. ```ts await angular .find("src/app/user-card.ts") .addInput("user", { type: "User", required: true, imports: [{ name: "User", from: "./user" }] }) .addInput("size", { type: '"sm" | "lg"', initial: '"sm"' }) .addModel("expanded", { type: "boolean", initial: "false" }) .addOutput("selected", { type: "User" }); ``` ```ts // before import { Component } from "@angular/core"; @Component({ selector: "app-user-card", template: "" }) export class UserCard {} // after import { Component, input, model, output } from "@angular/core"; import { User } from "./user"; @Component({ selector: "app-user-card", template: "" }) export class UserCard { readonly user = input.required(); readonly size = input<"sm" | "lg">("sm"); readonly expanded = model(false); readonly selected = output(); } ``` ```ts await angular .find("src/app/counter.ts") .addSignal("count", "0") .addComputed("double", "this.count() * 2"); // -> protected readonly count = signal(0); // -> protected readonly double = computed(() => this.count() * 2); ``` ```ts await angular .find("src/app/users.ts") .addInjection("http", "HttpClient", { imports: [{ name: "HttpClient", from: "@angular/common/http" }] }); ``` ```ts // before import { Injectable } from "@angular/core"; @Injectable({ providedIn: "root" }) export class Users { constructor() {} } // after import { Injectable, inject } from "@angular/core"; import { HttpClient } from "@angular/common/http"; @Injectable({ providedIn: "root" }) export class Users { private readonly http = inject(HttpClient); constructor() {} } ``` ## Format preservation [Section titled “Format preservation”](#format-preservation) A new entry follows the layout of the list it joins. A one-line list stays on one line, a multi-line list gets a new line at the indentation of its last entry, and a trailing comma stays trailing. Everything outside the inserted text is byte-identical. ## Rejections [Section titled “Rejections”](#rejections) An op rejects, and the file is left unchanged, when: * the file has no `@Component`, `@NgModule`, decorated class, `ApplicationConfig` variable, `bootstrapApplication` call, or routes for the op, or more than one; * the class already has a different member with the property’s name; * the metadata, config, or route list is not a literal (for example `providers: PROVIDERS`); * `bootstrapApplication` receives its config by name; * a route `path` starts with `/`, or a different route already uses the `path`; * an `element`, an import `name`, a `component`, an `export`, a `type`, or an `initial` is not valid, or `required` comes with `initial`. Like the TypeScript dialect, `find(path)` accepts any `.ts` path. A file without the target fails only when one of these ops runs. ## Custom edits with `.modify()` [Section titled “Custom edits with .modify()”](#custom-edits-with-modify) Use `.modify(fn)` for edits these ops do not cover, such as a child route or `viewProviders`, and for files the ops reject. The callback receives the live ts-morph `SourceFile`, and the module’s `astLibrary` export is ts-morph itself: ```ts import * as angular from "@pbuilder/sdk/angular"; await angular .find("src/app/user-card.ts") .addImport("CardState", "./card-state") .modify((ast) => { const viewProviders = ast .getClassOrThrow("UserCard") .getDecoratorOrThrow("Component") .getArguments()[0]! .asKindOrThrow(angular.astLibrary.SyntaxKind.ObjectLiteralExpression) .getPropertyOrThrow("viewProviders") .asKindOrThrow(angular.astLibrary.SyntaxKind.PropertyAssignment) .getInitializerIfKindOrThrow(angular.astLibrary.SyntaxKind.ArrayLiteralExpression); viewProviders.addElement("CardState"); }); ``` ## Next steps [Section titled “Next steps”](#next-steps) * [Modify](/guides/modify/): the TypeScript and React dialects and how a handle works. * [NestJS dialect](/guides/nestjs-dialect/): the same approach for a NestJS `@Module`. * [AST libraries](/reference/ast-libraries/): which library and version back each dialect. # CSS dialect > Set and remove declarations in .css files with @pbuilder/sdk/css, for example to sync design tokens into a Tailwind 4 file, plus the getDeclaration query and the .modify() escape hatch. `@pbuilder/sdk/css` sets and removes declarations in `.css` files. It is meant for files that mix generated values with hand-written CSS, such as a design-token file synced from a JSON export (Figma variables, for example). Each op edits only the declaration it addresses, and every other byte of the file stays as it was. The handle works like every other dialect handle: ops chained on it coalesce into one write, and `await` follows the rules in [How a dialect handle works](/guides/modify/#how-a-dialect-handle-works). ## Syncing tokens into a Tailwind 4 file [Section titled “Syncing tokens into a Tailwind 4 file”](#syncing-tokens-into-a-tailwind-4-file) tokens.css (before) ```css @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @theme { --color-primary: #0b254d; --radius: 4px; } ``` factory.ts ```ts import * as css from "@pbuilder/sdk/css"; export default async () => { await css .find("tokens.css") .setDeclaration({ atRule: "theme" }, "--color-primary", "#1974f9") .setDeclaration({ selector: "html[data-theme='nylon']" }, "--color-primary", "#0b254d"); }; ``` tokens.css (after) ```css @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @theme { --color-primary: #1974f9; --radius: 4px; } html[data-theme='nylon'] { --color-primary: #0b254d; } ``` The `@theme` value is updated where it is. The `nylon` mode block did not exist, so the op created it at the end of the file. The dialect takes resolved strings: turning token JSON into property names and values (aliases, modes) is your schematic’s job. `find(path)` accepts only `.css` files; `.scss` is not supported. Any other extension throws synchronously. The snippets in the rest of this page leave out the factory wrapper. ## Targets [Section titled “Targets”](#targets) A target names one top-level block: | Target | Block | | -------------------------------------------------------------------------------- | ------------------------------------- | | `{ selector: ":root" }` | the top-level rule `:root { }` | | `{ atRule: "theme" }` | the body of `@theme { }` | | `{ atRule: "theme", params: "inline" }` | the body of `@theme inline { }` | | `{ atRule: "media", params: "(prefers-color-scheme: dark)", selector: ":root" }` | the `:root` rule inside that `@media` | * The selector and params match as written, after trimming whitespace. `html[data-theme='nylon']` and `html[data-theme="nylon"]` are different targets, and setting a value on the second one creates a new block next to the first. * The at-rule name is written without `@` and matches case-insensitively. * Leaving out `params` matches only an at-rule with no params. `{ atRule: "theme" }` does not match `@theme inline { }`: on a file that has only `@theme inline`, it creates a new `@theme { }` block at the end of the file. Use `{ atRule: "theme", params: "inline" }`. * An at-rule statement with no body, such as `@layer base;` or `@import`, is never a target. * A target that matches more than one block rejects. ## Placement [Section titled “Placement”](#placement) * A missing block is created at the end of the file. It always lands after `@import`, which must come before every other rule, and after any hand-written header. A rule inside an at-rule, such as `:root` inside `@media`, is created at the end of that at-rule. * A new block uses the file’s line ending (`\n` or `\r\n`) and the indentation of its first top-level declaration. With no declaration to copy, it indents with two spaces. * A new declaration goes last in its block, or next to an existing one with `{ before }` or `{ after }`. * An existing declaration is updated in place and the anchor is ignored, so a second run never reorders the block. ```ts css.find("tokens.css").setDeclaration({ atRule: "theme" }, "--color-accent", "#ff0", { after: "--color-primary" }); // -> @theme { // -> --color-primary: #0b254d; // -> --color-accent: #ff0; // -> --radius: 4px; // -> } ``` A target with an at-rule and a selector creates both when they are missing: ```ts css.find("tokens.css").setDeclaration( { atRule: "media", params: "(prefers-color-scheme: dark)", selector: ":root" }, "--color-primary", "#9ec5ff", ); // appended to the file: // -> @media (prefers-color-scheme: dark) { // -> :root { // -> --color-primary: #9ec5ff; // -> } // -> } ``` ## Property names and values [Section titled “Property names and values”](#property-names-and-values) * Regular property names compare case-insensitively, so `COLOR` updates `color`. Custom properties compare case-sensitively, as in CSS: `--Brand` and `--brand` are two declarations. * A value is raw CSS, such as `#1974f9`, `var(--blue-500)`, or `calc(1rem + 2px)`, and must be exactly one declaration value. `;` and `}` are allowed inside strings, as in `content: ";"`. * `!important` goes in the `important` option, never in the value. `{ important: true }` writes it, and leaving the option out removes it from an existing declaration. * The dialect does not reason about shorthands. Setting `margin-top` on a block that has `margin: 8px` adds `margin-top` and leaves `margin` alone. ## Ops [Section titled “Ops”](#ops) ### `setDeclaration(target, property, value, options?)` [Section titled “setDeclaration(target, property, value, options?)”](#setdeclarationtarget-property-value-options) Sets `property` to `value` in the target block. An existing declaration is updated in place. A new one goes last in the block, or next to `options.before` or `options.after`. A missing block is created at the end of the file. `options.important: true` writes `!important`. Setting the value a declaration already has writes nothing. ```ts // :root before: color-scheme: light !important; css.find("tokens.css").setDeclaration({ selector: ":root" }, "color-scheme", "light"); // -> :root { // -> color-scheme: light; // -> } ``` ### `removeDeclaration(target, property)` [Section titled “removeDeclaration(target, property)”](#removedeclarationtarget-property) Removes `property` from the target block. A missing block or declaration is a no-op, so a sync can remove tokens that may already be gone. The block stays, even when it ends up empty. ```ts css .find("tokens.css") .removeDeclaration({ atRule: "theme" }, "--radius") .removeDeclaration({ selector: ".missing" }, "--x"); // -> @theme { // -> --color-primary: #0b254d; // -> } ``` ## Rejections [Section titled “Rejections”](#rejections) Every rejection throws before the file changes. The message starts with `dialect operation failed: `and names the op and the target: ```text dialect operation failed: setDeclaration(@theme) — `red; color: blue` is not a single declaration value. ``` A rejected op leaves the file unchanged, and so do the ops chained before it on the same handle. The ops reject: * a property name that is not a CSS identifier or a `--` custom property; * a value that would close the declaration or add another one (`red; color: blue`, `red}`), a value that carries `!important`, an empty value, and a value that carries a comment; * a target that is not `{ selector }`, `{ atRule, params? }`, or both, an at-rule name written with `@`, and a selector or params that would add another block; * a target that matches more than one block; * a property declared more than once in the block, such as the fallback `display: -webkit-box; display: flex;` (edit those with [`.modify()`](#custom-edits-with-modify)); * an anchor that is not declared in the block, and passing both `before` and `after`. ## Custom edits with `.modify()` [Section titled “Custom edits with .modify()”](#custom-edits-with-modify) `.modify(fn)` hands `fn` the live PostCSS `Root`. The module exports the `postcss` package as `astLibrary`, so you do not need to add `postcss` to your own dependencies. PostCSS keeps every whitespace, comment, and BOM byte, so a file no op touched prints byte-identical. ```ts await css.find("app.css").modify((root) => { root.walkRules(".legacy", (rule) => rule.remove()); }); ``` ### Reading a value with `getDeclaration` [Section titled “Reading a value with getDeclaration”](#reading-a-value-with-getdeclaration) On the handle, `getDeclaration(target, property)` is a [query](/guides/modify/#queries). It ends the chain and resolves to `{ value, important }`, or `undefined` when the block or declaration is missing. It sees every op chained before it, and those ops still commit: ```ts const primary = await css .find("tokens.css") .setDeclaration({ atRule: "theme" }, "--color-primary", "#1974f9") .getDeclaration({ atRule: "theme" }, "--color-primary"); // primary: { value: "#1974f9", important: false } ``` The module also exports `getDeclaration(root, target, property)` as a plain function. It takes a parsed `Root` and returns `{ value, important }`, or `undefined` when the block or declaration is missing. It follows the same target, name, ambiguity, and duplicate rules as the ops, and throws the same errors. Call it inside `.modify(fn)` to decide what to change. This factory updates `--color-primary` in `@theme` only when it still has the old value: factory.ts ```ts import * as css from "@pbuilder/sdk/css"; export default async () => { await css.find("tokens.css").modify((root) => { if (css.getDeclaration(root, { atRule: "theme" }, "--color-primary")?.value === "#0b254d") { // A bare root.walkDecls would also rewrite the html[data-theme='nylon'] block. root.walkAtRules("theme", (theme) => theme.walkDecls("--color-primary", (decl) => { decl.value = "#1974f9"; }), ); } }); }; ``` tokens.css (after) ```css @import "tailwindcss"; @custom-variant dark (&:where(.dark, .dark *)); @theme { --color-primary: #1974f9; --radius: 4px; } html[data-theme='nylon'] { --color-primary: #0b254d; } ``` `getDeclaration` also works on a `Root` you parse yourself with `astLibrary.parse`: ```ts const root = css.astLibrary.parse((await css.find("tokens.css").read()) ?? ""); css.getDeclaration(root, { selector: ":root" }, "--radius"); // { value: "4px", important: false } ``` Edits made in `.modify()` skip the ops’ checks. The general [rules for custom edits](/guides/modify/#rules-for-custom-edits) apply here too. ## Going further [Section titled “Going further”](#going-further) * [AST libraries](/reference/ast-libraries/): which library and version back each dialect. * The SDK’s [CSS dialect guide](https://github.com/Project-Builder-Schematics/project-builder-sdk/blob/main/docs/css-dialect.md). # Error handling > The AuthoringError contract that every rejected verb throws. Its verb, path, and reason fields, the fail-closed guarantee behind it, and how to write factories that fail well. An authoring verb that gets rejected, either by the engine or by the SDK itself before any round-trip, throws `AuthoringError`. It carries everything you need to recover, in author vocabulary. ## Fail-closed by design [Section titled “Fail-closed by design”](#fail-closed-by-design) A failed run writes **nothing**. Verbs only schedule directives against the engine’s staging tree; disk writes happen at Commit, after your factory returns. A thrown `AuthoringError` (or any error) before return therefore leaves the target project exactly as it was. There is no partial-write state to clean up, which is why the verbs themselves are strict: `create` rejects an existing path, `replaceContent` rejects a missing one, and overwrites require an explicit `{ force: true }`. See [Mutation verbs](/guides/mutation-verbs/) for each verb’s exact semantics. ## How errors surface [Section titled “How errors surface”](#how-errors-surface) Calling the runner directly throws; `runFactoryForTest` captures the same `AuthoringError` on `result.error`: ```ts import { runFactoryForTest } from "@pbuilder/sdk/testing"; import { AuthoringError } from "@pbuilder/sdk/commons"; const result = await runFactoryForTest(run, input); if (result.error instanceof AuthoringError) { switch (result.error.reason) { case "path-collision": console.error(`${result.error.verb} collided at ${result.error.path}`); break; default: console.error(result.error.message); } } ``` The [testing guide](/getting-started/testing/) covers the harness itself. ## When a factory throws a plain `Error` [Section titled “When a factory throws a plain Error”](#when-a-factory-throws-a-plain-error) Not every failure is an `AuthoringError`. When your factory throws an ordinary `Error` (`throw new Error(...)`, or an error from a library it calls), the runner prints its message on stderr as `pbuilder-runner: `, cut off at 2,000 characters. Before printing, it rewrites the absolute paths in the message so they don’t reveal where things live on your machine: | Path in your message | Shown as | | --------------------------------- | -------------------------------------------------------------------------------------- | | Under the project root | `/...`, for example `open '/src/a.ts'` | | Under the `@pbuilder/sdk` package | `/...` | | A Windows, UNC or WSL path | `` | | Any other POSIX absolute path | a `../`-relative path from the project root | | A URL | left as it is, except that a `file://` URL keeps `file://` while its path is rewritten | So a factory that fails with ```text ENOENT: no such file or directory, open '/home/ana/acme/src/a.ts' ``` shows up as ```text pbuilder-runner: ENOENT: no such file or directory, open '/src/a.ts' ``` The runner inserts `` and `` on purpose. Seeing one in an error doesn’t mean your factory or a template is broken. Caution The rewrite is best-effort and only covers path shapes. Anything else you interpolate into a message, such as an environment variable’s value or a token, is printed as is. Keep secrets out of error messages. ## Fields [Section titled “Fields”](#fields) * **`verb`**: the author-facing verb whose call was rejected: `"create"`, `"modify"`, `"remove"`, `"rename"`, `"move"`, `"copy"`, or `"copyIn"`. `undefined` for batch-level rejections that have no single offending call. * **`path`**: the author-declared, source-side path for the failing call. `undefined` when `verb` is `undefined`. * **`reason`**: the closed cause of the rejection (see below). * **`origin`**: derived from `reason`: `"write-rejected"` (the engine refused a write) or `"authoring-rejected"` (the SDK caught a misuse before any engine round-trip). * **`appliedCount`**: how many directives applied within the failing run before the offender. It is a diagnostic only. A rejected run discards everything, so this count never promises partial persistence. Note **The `"modify"` label quirk.** `"modify"` labels the underlying wire mutation, and is shared by both `.replaceContent()` calls (the commons/dialect wholesale replace) and a dialect handle’s `.modify(fn)` AST escape hatch. The two calls lower to the same wire directive, so a rejection on either surfaces as `verb: "modify"`. This is deliberate, not a stale name left over from a rename. Assert against `"modify"`, never `"replaceContent"`. ## `reason` values [Section titled “reason values”](#reason-values) `reason` is a closed union. Exhaustive `switch` blocks are expected, and they get a compile error if a value is missed: | `reason` | Meaning | | ------------------------- | --------------------------------------------------------------------------------------- | | `path-collision` | The target path already exists and `{ force: true }` was not passed. | | `path-not-found` | The target path does not exist. | | `unrepresentable-content` | The content could not be represented in the engine’s format. | | `changes-too-large` | The run’s total change size exceeds the engine’s cap. | | `outside-session` | An authoring verb was called outside an active session. | | `unknown` | The rejection could not be classified. | | `invalid-input` | The SDK rejected a call’s arguments before any engine round-trip. | | `reserved-name` | The call used a name reserved by the SDK or engine. | | `source-not-found` | A package-local source (`scaffold`/`copyIn`/`create({ templateFile })`) does not exist. | | `source-not-regular-file` | A package-local source is not a regular file. | | `source-unreadable` | A package-local source exists but could not be read. | Caution `source-outside-package` was removed from `@pbuilder/sdk`. The SDK no longer re-derives a containment ceiling for package-local sources. Migration: drop the `case "source-outside-package":` arm from any exhaustive `switch (err.reason)`. TypeScript will point at it. ## Catching and recovering [Section titled “Catching and recovering”](#catching-and-recovering) This is the same shape as above, now exhaustive, with a case for every `reason` value: ```ts switch (err.reason) { case "path-collision": console.error(`${err.verb} collided at ${err.path}`); break; case "path-not-found": case "unrepresentable-content": case "changes-too-large": case "outside-session": case "unknown": case "invalid-input": case "reserved-name": case "source-not-found": case "source-not-regular-file": case "source-unreadable": console.error(err.message); break; } ``` ## Writing good failure paths [Section titled “Writing good failure paths”](#writing-good-failure-paths) Write factories that treat rejection as an expected outcome: * Prevent `path-collision` by reading first. The most common rejection is a re-run hitting a file the previous run created. Branch on [`find().read()`’s trichotomy](/guides/mutation-verbs/#reading-files-findpathread) (create when absent, update when present) instead of reaching for `{ force: true }`, which turns every re-run into a silent overwrite. * Pass `force: true` only when overwriting is the intent. Fail-closed collisions are the SDK protecting earlier output; forcing them away removes that protection for this run and every future one. * Let unexpected states fail loud. A rejected run discards everything and leaves the tree untouched, so throwing is always safe. A factory that detects a state it doesn’t understand should throw rather than guess. * Switch exhaustively on `reason`. The closed union means the compiler tells you when a new SDK version adds (or removes) a rejection cause, as the `source-outside-package` removal above shows. * In tests, assert on the structured fields. `result.error.verb`, `.path`, and `.reason` pin down *which* directive failed and *why*, which makes them far more mutation-resistant than matching on `message` text. Remember the `"modify"` label quirk when the failing call is a `replaceContent`. ## Next steps [Section titled “Next steps”](#next-steps) * [Mutation verbs](/guides/mutation-verbs/): the seven authoring verbs and the read-trichotomy rule. * [Pending directives](/guides/pending-directives/): preview a factory’s planned changes before anything commits. # Evaluate without adopting the SDK > Try Project Builder in an existing repository without declaring @pbuilder/sdk in package.json, and see what init still changes, how to keep the trial local, and how to clean up or adopt. You want to try Project Builder in a repository you already have, without adding `@pbuilder/sdk` to its `package.json` and without restoring that file after every run. `builder init --no-sdk-dependency` gives you that: the CLI skips the SDK declaration and its install, and `builder execute` runs against an SDK that is already present in `node_modules`. This is not an “untouched repository” mode `--no-sdk-dependency` only keeps `package.json` out of it. `init` still: * creates or merges `project-builder.json`, * creates `schematics/.gitkeep`, * writes `.claude/skills/pbuilder/` (`SKILL.md`, `use.md`, `choose.md`, `create.md`), * adds or updates a marker block in `AGENTS.md` or `CLAUDE.md`, so **a tracked agent file can change** while `package.json` stays untouched. Schematics you run write their own files. If `init` fails part-way, the outputs written before the failure stay on disk. ## Before you start [Section titled “Before you start”](#before-you-start) * The `builder` CLI and Bun are installed. See [Installation](/getting-started/installation/). * You know what is already modified, so you can tell trial changes apart later: ```sh git status --short ``` ## Run the trial [Section titled “Run the trial”](#run-the-trial) 1. **Preview what `init` will do.** Nothing is written or installed: ```sh builder init . --no-sdk-dependency --dry-run --non-interactive ``` With `--no-sdk-dependency` alone, the plan contains no `package.json` step at all. The skill and marker lines are a plan, not an exact prediction of what happens to files that already exist. 2. **Initialise the workspace:** ```sh builder init . --no-sdk-dependency --non-interactive ``` `init` adds no SDK entry, no `generate:types` script and runs no install. It also **removes nothing**: an SDK version or script already in `package.json` stays, and an SDK already installed stays usable. The warning it prints lists the ways to provide an SDK; the next step is one of them. To get the `generate:types` script anyway, add `--skip-types-script=false`. The full flag matrix is in the [`builder init` reference](/reference/cli-commands/#package-setup-flags). 3. **Put an SDK in `node_modules` without declaring it.** `execute` needs a complete SDK at `node_modules/@pbuilder/sdk`. With npm or Bun you can install one without touching `package.json` or the lockfile: ```sh npm install --no-save @pbuilder/sdk # or bun add --no-save @pbuilder/sdk ``` pnpm and Yarn have no equivalent option: their add commands always record the dependency. A later install or prune by your package manager can remove an unsaved package; if that happens, run the command again. Skip this step if the repository already has an SDK installed. **Alternative: use a global SDK.** Instead of installing into `node_modules`, point [`sdk.root`](/reference/cli-commands/#external-sdk-root-sdkroot) in `project-builder.json` at a global install, for example the directory `bun add -g @pbuilder/sdk` creates: project-builder.json ```json { "sdk": { "root": "/Users/me/.bun/install/global/node_modules/@pbuilder/sdk" } } ``` The first committing `builder execute` then creates a single symbolic link, `node_modules/@pbuilder/sdk`, pointing at that root. To regenerate types by hand in this setup, see [Type generation with `sdk.root`](/guides/type-generation/#when-the-sdk-comes-from-sdkroot). To leave `project-builder.json` untouched as well, name the same directory per run with `--sdk-root` (before `:`) or once with the `BUILDER_SDK_ROOT` environment variable instead. Both override `sdk.root`. See [SDK source](/reference/cli-commands/#sdk-source). 4. **Create and run a schematic:** ```sh builder new schematic hello builder execute default:hello ``` `new schematic` writes `schematics/hello/` and registers it in `project-builder.json`. The generated factory is an empty stub, so this first run reports no changes. From here on, follow [Your first schematic](/getting-started/your-first-schematic/) to give it real logic. Schematic runs write to your working tree `builder execute` has no preview for native schematics: `--dry-run` is rejected, not simulated. Put CLI flags **before** `:`. After it, `--dry-run` is just a schematic input and does not stop any write. Review `git diff` after every run. ## Choose the SDK version [Section titled “Choose the SDK version”](#choose-the-sdk-version) With no declaration anywhere, `execute` requires SDK **0.2.4** or newer, the oldest version the CLI is tested against. To require a specific floor without adding a dependency, set it in `project-builder.json`: ```json { "sdk": { "version": "0.2.4" } } ``` A declaration in `package.json` still takes priority over `sdk.version`, and an invalid value fails instead of falling back. The full selection order is in the [SDK requirement](/reference/cli-commands/#sdk-requirement) reference; each failure reason and its repair is in [SDK diagnostics](/reference/cli-output-and-errors/#sdk-diagnostics). ## Keep trial files out of Git [Section titled “Keep trial files out of Git”](#keep-trial-files-out-of-git) The CLI never edits Git’s exclude files, so do it yourself if you want `git status` to stay quiet during the trial. Resolve the right file first; in a worktree, `.git` is not a directory: ```sh git rev-parse --git-path info/exclude ``` Then add the entries that apply, and **only** for paths that are currently untracked: ```text /project-builder.json /.claude/skills/pbuilder/ /schematics/ ``` Local excludes affect untracked files only. They do not hide changes to tracked files: an agent-file marker, a manifest, a lockfile or generated code still show up in `git diff`, and you still need to review them. Do not use `git update-index --skip-worktree` or `--assume-unchanged` to hide those instead. Never exclude a `schematics/` directory that already existed in the repository. ## Clean up [Section titled “Clean up”](#clean-up) Work from what the trial actually changed: 1. Run `git status --short` and `git diff`, and compare with the status you noted before the trial. 2. Remove only files the trial created: `project-builder.json`, `schematics/` and `.claude/skills/pbuilder/`, when they did not exist before. 3. Undo only trial changes in tracked files, such as the marker block added to `AGENTS.md` or `CLAUDE.md`. Keep any edits of your own. 4. Remove the trial SDK from `node_modules` if you installed one. If you used an external root, remove the link it left behind (`test -L node_modules/@pbuilder/sdk && rm node_modules/@pbuilder/sdk`) after removing the `sdk.root` key or unsetting `BUILDER_SDK_ROOT`. `--sdk-root` leaves nothing else to undo. Also remove the lines you added to the exclude file. Do not reach for `git clean` or `git reset --hard`: they also destroy work that has nothing to do with the trial. ## Adopt Project Builder [Section titled “Adopt Project Builder”](#adopt-project-builder) When the trial convinces you: 1. Remove the exclude lines you added, and decide which of `project-builder.json`, `schematics/` and `.claude/skills/pbuilder/` to commit. 2. Declare the SDK with **one** package-manager command, pinning a version if you need reproducible installs: | Package manager | Command | | --------------- | -------------------------------------- | | npm | `npm install --save-dev @pbuilder/sdk` | | pnpm | `pnpm add -D @pbuilder/sdk` | | Yarn | `yarn add --dev @pbuilder/sdk` | | Bun | `bun add -D @pbuilder/sdk` | This changes `package.json` and may change the lockfile. 3. Optionally let `init` add the missing `generate:types` script. `--force` also merges `project-builder.json` and regenerates the skill files and marker, so preview first and review the full diff afterwards: ```sh builder init . --force --dry-run --non-interactive builder init . --force --non-interactive ``` ## Related [Section titled “Related”](#related) * [`builder init`](/reference/cli-commands/#builder-init): outputs, flags and the `package.json` edit contract. * [`builder execute`](/reference/cli-commands/#builder-execute): running schematics and the SDK requirement. * [CLI output and errors](/reference/cli-output-and-errors/): every error code and its remedy. # External collections > Run schematics that live outside the working directory, for example from a git worktree, with --manifest or BUILDER_MANIFEST, while generated files still land where you run the command. By default, `builder execute` reads `project-builder.json`, and every collection, factory and schema it registers, from the working directory. `--manifest` points those reads somewhere else on the same machine, while generated files still land in the working directory. The motivating case is git worktrees: your schematics are not committed to the application repository, so a worktree created next to the main checkout does not have them. With `--manifest`, every checkout runs the same schematics from one place. ## Two roots [Section titled “Two roots”](#two-roots) | Root | What it is | What it is used for | | ----------------- | ----------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Manifest root** | The directory named by `--manifest` (or the working directory when it is not given) | All reads: `project-builder.json`, `collection.json`, factories, `schema.json`, templates. Paths registered in the manifest resolve against this directory. | | **Workspace** | The working directory | All writes: generated files, and the `@pbuilder/sdk` the run uses | Without `--manifest` both roots are the working directory, which is why you normally never notice the difference. Local directories only `--manifest` and `BUILDER_MANIFEST` accept only a path on this machine. Any value that starts with a URL scheme (`https://`, `git://`, `ssh://`, `file://`) or a drive letter is rejected. Nothing is fetched, cloned or cached. ## Set up a shared collections directory [Section titled “Set up a shared collections directory”](#set-up-a-shared-collections-directory) Keep the schematics in a directory of their own, outside the application checkouts, with **no `node_modules` in it or above it**: ```text ~/work/ ├── app-schematics/ ← manifest root │ ├── project-builder.json │ └── schematics/ │ └── hello/ │ ├── factory.ts │ ├── schema.json │ └── schema.generated.ts ├── app/ ← main checkout └── app-feature-x/ ← git worktree of app ``` app-schematics/project-builder.json ```json { "collections": { "default": { "hello": { "path": "./schematics/hello" } } } } ``` The manifest root must not carry its own SDK A factory resolves `@pbuilder/sdk` from its own location first. If the manifest root, or any directory above it, has a `node_modules/@pbuilder/sdk`, the factory loads that copy while the run uses the workspace’s copy. Two copies are never mixed: the run fails with `engine_native_developer_fault` and a note about a *split module graph*, even when both copies are the same version. This is why schematics kept inside the main checkout, which has its own `node_modules`, cannot be run from a worktree with `--manifest`. Move them to a directory of their own. 1. **Install the SDK once, outside the checkouts.** ```sh bun add -g @pbuilder/sdk ``` The package directory this creates, `~/.bun/install/global/node_modules/@pbuilder/sdk`, is the **SDK root**. For other global installs, see [what the root must be](/reference/cli-commands/#external-sdk-root-sdkroot). 2. **Run the schematic, naming both roots.** `--manifest` and `--sdk-root` go **before** `:`: ```sh cd ~/work/app-feature-x builder execute --manifest=../app-schematics \ --sdk-root=$HOME/.bun/install/global/node_modules/@pbuilder/sdk \ default:hello --name=payments ``` The factory and its schema are read from `~/work/app-schematics`; the generated file is written to `~/work/app-feature-x/src/services/payments.ts`. The checkout needs no `node_modules` of its own: the only other entry the run writes is `node_modules/@pbuilder/sdk`, a link to the SDK root. 3. **Inspect the collection the same way:** ```sh builder info --manifest=../app-schematics default ``` The same command works from the main checkout. Every checkout, worktree or not, uses the shared directory and the shared SDK. A checkout with its own SDK The order in which `execute` picks an SDK is in [SDK source](/reference/cli-commands/#sdk-source). Two cases to watch: * A checkout that already has a real `node_modules/@pbuilder/sdk` fails with `execute_sdk_link_path_conflict` when you name a different root. Either remove that install, or run that checkout without `--sdk-root` and with `BUILDER_SDK_ROOT` unset, so the run uses it. * A `node_modules` that is a symlink to another checkout is rejected as escaping the workspace, with or without `--sdk-root`. ## How the value is read [Section titled “How the value is read”](#how-the-value-is-read) * **A directory or the file itself.** `--manifest=../app-schematics` and `--manifest=../app-schematics/project-builder.json` are equivalent. * **Relative or absolute.** A relative value resolves against the working directory. The path is canonicalised once, following symbolic links. * **Naming the working directory** is the same as not passing the flag. * **Placement.** For `execute`, `--manifest` must come before `:`. After it, it is rejected with `invalid_input` instead of being passed to the schematic. `info` accepts it before or after its argument. ## Set it once with environment variables [Section titled “Set it once with environment variables”](#set-it-once-with-environment-variables) Tools that spawn `builder` inside a checkout (scripts, editors, agents) can set `BUILDER_MANIFEST` and `BUILDER_SDK_ROOT` instead of passing the flags. Set once, they let every checkout run the shared schematics with plain commands: ```sh export BUILDER_MANIFEST=~/work/app-schematics export BUILDER_SDK_ROOT=~/.bun/install/global/node_modules/@pbuilder/sdk builder execute default:hello --name=orders ``` * Each flag always wins over its variable. A flag that names an unusable directory fails on its own; it never falls back to the variable. * When `BUILDER_MANIFEST` points somewhere other than the working directory, every run prints the `warn_manifest_root_ambient` warning with the resolved path, so an ambient setting never goes unnoticed. A root from `BUILDER_SDK_ROOT` prints `warn_sdk_root_ambient`, which names the variable but **not** the path, so check the variable itself to see which SDK ran. * The shell expands `~` in the `export` lines above. A settings file for an editor or agent does not, so write full paths there. * Stopped using `BUILDER_SDK_ROOT`? The link it left in each checkout makes the next run fail with `execute_manifest_path_escape`. See [`--sdk-root` and `BUILDER_SDK_ROOT`](/reference/cli-commands/#--sdk-root-and-builder_sdk_root). ## Trust rules [Section titled “Trust rules”](#trust-rules) The manifest root decides which code runs, so the CLI only accepts one that nobody else can modify. It refuses: * a root that is writable by its group or by other users; * a root with a directory above it that is **group**-writable, or world-writable without the sticky bit; * a root, or a directory above it, owned by a user other than you or root; * the filesystem root, and your home directory itself (a directory inside your home is fine). Note On macOS and many Linux systems `/tmp` is writable by its group, so a manifest root under `/tmp` is refused. Use a directory inside your home directory. ## What stays in the workspace [Section titled “What stays in the workspace”](#what-stays-in-the-workspace) * **`builder new schematic`** always writes to the working directory. If `--manifest` or `BUILDER_MANIFEST` names a different directory, it refuses with `manifest_scoped_authoring_refused` before writing anything. To author in the shared directory, `cd` into it and run `builder new schematic` there. The shared directory has no SDK, and `new schematic` does not read `BUILDER_SDK_ROOT`, so the scaffold skips `schema.generated.ts` with a warning. Regenerate types with the SDK root’s own script. Codegen writes inside the project you name: ```sh node $BUILDER_SDK_ROOT/dist/bin/pbuilder-codegen.js --project ~/work/app-schematics ``` * **SDK settings in the external manifest are ignored.** An `sdk.root` or `sdk.version` in the external `project-builder.json` is not used. `--sdk-root`, `BUILDER_SDK_ROOT` or the workspace’s own installed SDK supply the SDK, and the workspace’s own `package.json` sets the version requirement. When the external manifest declares an `sdk.root`, the run warns with `warn_manifest_sdk_root_ignored`. * **Other commands** do not accept `--manifest`: passing it to `init`, for example, fails with `manifest_root_unsupported_command`. ## Paths inside the manifest [Section titled “Paths inside the manifest”](#paths-inside-the-manifest) Every path the external manifest registers must be **relative** to the manifest root and stay inside it: * An absolute `path` fails with `collection_absolute_path`. * A path that resolves outside the manifest root, for example `../app/schematics/hello`, fails as escaping the named manifest root. ## Related [Section titled “Related”](#related) * [`builder execute`](/reference/cli-commands/#builder-execute) and [`builder info`](/reference/cli-commands/#builder-info): flag reference. * [CLI output and errors](/reference/cli-output-and-errors/#manifest-root): every `--manifest` error and warning. * [Evaluate without adopting the SDK](/guides/evaluate-without-sdk/): use an SDK in a checkout without editing `package.json`. # JSON dialect > Edit .json and .jsonc files by path with @pbuilder/sdk/json, using fifteen named ops that change only the bytes they must, plus the .modify() escape hatch. `@pbuilder/sdk/json` edits `.json` and `.jsonc` files structurally. Instead of reading `package.json`, parsing it, and writing the whole file back with `replaceContent`, you address a value by its **path** and call a named op. Each op changes only the bytes it must: comments, key order, blank lines, indentation, line endings, a UTF-8 BOM, and the trailing newline all survive. The handle works like every other dialect handle: ops chained on it coalesce into one write, and `await` follows the rules in [How a dialect handle works](/guides/modify/#how-a-dialect-handle-works). ## Opening a file [Section titled “Opening a file”](#opening-a-file) package.json (before) ```json { "name": "app" } ``` factory.ts ```ts import * as json from "@pbuilder/sdk/json"; export default async () => { await json .find("package.json") .setValue(["scripts", "build"], "tsc") .appendItem(["files"], "dist", { unique: true }); }; ``` package.json (after) ```json { "name": "app", "scripts": { "build": "tsc" }, "files": [ "dist" ] } ``` `find(path)` accepts a file name that ends in `.json` or `.jsonc` and has a name before the extension. Any other path, such as `config.yaml`, `README`, or a bare `.json`, throws synchronously. A file that is not valid JSON fails with `could not parse ""`; comments and trailing commas are allowed. An empty file is a document with no value, and `setValue` creates its root. The snippets in the rest of this page leave out the factory wrapper. ## Paths and values [Section titled “Paths and values”](#paths-and-values) A path is an array of segments. A `string` segment names an object key, a `number` segment indexes an array, and `[]` is the document root: ```ts ["scripts", "build"] // the "build" key inside "scripts" ["plugins", 0] // the first item of "plugins" [] // the whole document ``` Keys that contain dots or slashes need no escaping, because a path is never a joined string. Number segments must be non-negative integers. Values are typed `JsonValue`: `null`, booleans, finite numbers, strings, arrays, and plain objects. Both `JsonValue` and `JsonPath` are exported as types from `@pbuilder/sdk/json`. An `as never` cast or an untyped JavaScript factory can still pass any value past the type, so every value is also validated at runtime, before the document changes. These values reject, and the error names the path inside the value: * `undefined`, functions, symbols, and `bigint` * `NaN` and `Infinity` * class instances such as `Date` and `Map` * cyclic values ```ts await json.find("config.json").setValue(["when"], new Date() as never); // rejects: value at [] is a Date instance, which JSON cannot represent. ``` ## Rejections [Section titled “Rejections”](#rejections) Every rejection is an error whose message starts with `dialect operation failed: `and names the op and path, for example: ```text dialect operation failed: addKey(["a"]) — the key already exists with a different value. ``` A rejected op emits no directive. A path that does not fit the document also rejects: a segment that crosses a scalar, a `number` segment on an object, or a `string` segment on an array. Only `setValue`, `merge`, `appendItem`, `updateValue`, and the destinations of `moveValue` and `copyValue` create missing parents, and they only create objects. A path whose missing parent would have to be an array rejects. ## Keys [Section titled “Keys”](#keys) ### `setValue(path, value, options?)` [Section titled “setValue(path, value, options?)”](#setvaluepath-value-options) Upserts the value at `path`, creating missing parent objects. `setValue([], value)` replaces the whole document. Setting a value deep-equal to the current one leaves the bytes unchanged. ```ts // package.json before: // { // "name": "app" // } await json.find("package.json").setValue(["scripts", "build"], "tsc"); // -> { // -> "name": "app", // -> "scripts": { // -> "build": "tsc" // -> } // -> } ``` `options` is `{ before?: string; after?: string }`. It places a newly created key next to an existing sibling, with the same rules as `addKey`, and needs a path that ends in an object key. ### `addKey(path, value, options?)` [Section titled “addKey(path, value, options?)”](#addkeypath-value-options) Adds an object key that does not exist yet. When the key exists with a deep-equal value, the op is a no-op, so it is safe to re-run. When it exists with a different value, the op rejects. The parent object must exist. `{ before }` or `{ after }` places the new key next to a sibling. An absent sibling, or both options at once, reject. The option has no effect when the key already exists. ```ts // package.json before: // { // "name": "app", // "version": "1.0.0" // } await json.find("package.json").addKey(["private"], true, { after: "name" }); // -> { // -> "name": "app", // -> "private": true, // -> "version": "1.0.0" // -> } ``` ### `removeKey(path)` [Section titled “removeKey(path)”](#removekeypath) Removes the member and its separator comma. An absent key is a no-op. ```ts // config.json before: { "a": 1, "b": 2 } await json.find("config.json").removeKey(["b"]); // -> { "a": 1 } ``` ### `renameKey(path, newKey)` [Section titled “renameKey(path, newKey)”](#renamekeypath-newkey) Renames a key in place, keeping its value, position, and comments. When the old key is absent and `newKey` already exists, the key was already renamed and the op is a no-op. When both keys exist, or neither does, the op rejects. ```ts // config.json before: { "a": 1, "b": 2, "c": 3 } await json.find("config.json").renameKey(["b"], "beta"); // -> { "a": 1, "beta": 2, "c": 3 } ``` ## Arrays [Section titled “Arrays”](#arrays) Every array op rejects when the value at `path` exists and is not an array. ### `insertItem(path, index, value)` [Section titled “insertItem(path, index, value)”](#insertitempath-index-value) Inserts `value` before the item at `index`. `index` equal to the array length appends. An index outside `0..length` rejects. ```ts // config.json before: { "x": [1, 3] } await json.find("config.json").insertItem(["x"], 1, 2); // -> { "x": [1, 2, 3] } ``` ### `appendItem(path, value, options?)` [Section titled “appendItem(path, value, options?)”](#appenditempath-value-options) Appends `value` at the end, creating the array when it is absent. With `{ unique: true }`, the op skips the append when a deep-equal item already exists, which makes it safe to re-run. ```ts // config.json before: { "plugins": ["a"] } await json.find("config.json").appendItem(["plugins"], "b", { unique: true }); // -> { "plugins": ["a", "b"] } ``` ### `removeItem(path, index)` [Section titled “removeItem(path, index)”](#removeitempath-index) Removes the item at `index` and its separator. An out-of-range index rejects. ```ts // config.json before: { "x": [1, 2] } await json.find("config.json").removeItem(["x"], 0); // -> { "x": [2] } ``` ### `removeValue(path, value)` [Section titled “removeValue(path, value)”](#removevaluepath-value) Removes every item deep-equal to `value`. No match, or nothing at `path`, is a no-op. A value at `path` that is not an array rejects. ```ts // config.json before: { "x": ["a", "b", "a"] } await json.find("config.json").removeValue(["x"], "a"); // -> { "x": ["b"] } ``` ### `replaceItem(path, index, value)` [Section titled “replaceItem(path, index, value)”](#replaceitempath-index-value) Replaces the item at `index` in place. An out-of-range index rejects. A deep-equal value is a no-op. ```ts // config.json before: { "x": [1, 2] } await json.find("config.json").replaceItem(["x"], 1, 9); // -> { "x": [1, 9] } ``` ### `moveItem(path, from, to)` [Section titled “moveItem(path, from, to)”](#moveitempath-from-to) Moves the item at index `from` so it sits at index `to`. The other items keep their relative order. Out-of-range indices reject. ```ts // config.json before: { "x": [1, 2, 3] } await json.find("config.json").moveItem(["x"], 0, 2); // -> { "x": [2, 3, 1] } ``` ## Values [Section titled “Values”](#values) ### `merge(path, value)` [Section titled “merge(path, value)”](#mergepath-value) Deep-merges a plain object into the object at `path`, creating it when absent. Two plain objects merge recursively. Any other pair, arrays included, is replaced by the incoming value. Keys that exist only in the document are kept, and members the merge does not change keep their bytes. Merging the same value again is a no-op. A target or `value` that is not a plain object rejects. ```ts // config.json before: { "a": { "x": 1, "y": [1] }, "b": true } await json.find("config.json").merge([], { a: { y: [2], z: 3 } }); // -> { "a": { "x": 1, "y": [2], "z": 3 }, "b": true } ``` ### `updateValue(path, fn)` [Section titled “updateValue(path, fn)”](#updatevaluepath-fn) Calls `fn` with the current value, or `undefined` when it is absent, and sets what `fn` returns. The returned value is validated like any other value. ```ts // package.json before: { "version": 1 } await json .find("package.json") .updateValue(["version"], (v) => (typeof v === "number" ? v + 1 : 1)); // -> { "version": 2 } ``` ### `moveValue(from, to)` [Section titled “moveValue(from, to)”](#movevaluefrom-to) Moves a value, like the RFC 6902 `move` operation: removes it at `from` and writes it at `to`, replacing any value already there. A `number` last segment in `to` inserts into that array at that index; in `from`, it removes that item. When `from` is absent and `to` exists, the value was already moved and the op is a no-op. The op rejects when both are absent, when `to` lies inside `from`, or when `from` is the document root. ```ts // config.json before: // { // "a": { // "x": 1 // }, // "b": { // "y": 2 // } // } await json.find("config.json").moveValue(["a", "x"], ["b", "x"]); // -> { // -> "a": {}, // -> "b": { // -> "y": 2, // -> "x": 1 // -> } // -> } ``` ### `copyValue(from, to)` [Section titled “copyValue(from, to)”](#copyvaluefrom-to) Copies a value with `setValue`’s rules, like the RFC 6902 `copy` operation. A deep-equal value already at `to` is a no-op. An absent `from` rejects. ```ts // config.json before: { "a": [1] } await json.find("config.json").copyValue(["a"], ["b"]); // -> { "a": [1], "b": [1] } ``` ### `sortKeys(path, options?)` [Section titled “sortKeys(path, options?)”](#sortkeyspath-options) Orders the object’s members by key, in code-point order. Each member keeps the comments and indentation that precede it. `{ recursive: true }` also sorts every nested object, including objects inside arrays. An already sorted object is a no-op. A value that is not an object, or no value at all, rejects. ```ts // config.json before: { "b": 1, "a": 2 } await json.find("config.json").sortKeys([]); // -> { "a": 2, "b": 1 } ``` ## Comments and formatting [Section titled “Comments and formatting”](#comments-and-formatting) JSONC comments (`//` and `/* */`) are preserved. In an object or array where each member ends its own line, a member owns the comment lines above it and the trailing comment on its last line. Removing the member drops them, sorting moves them with it, and inserting next to it never separates them from it: ```ts // tsconfig.json before: // { // // second // "b": 1, // trailing b // "a": 2 // } await json.find("tsconfig.json").sortKeys([]); // -> { // -> "a": 2, // -> // second // -> "b": 1 // trailing b // -> } ``` There is no op to add, change, or remove a comment. Comment edits go through [`.modify()`](#custom-edits-with-modify). New content uses the indentation detected from the document (tabs, or the width of the first indented line, falling back to two spaces) and the document’s own line ending. Inside a one-line container, edits stay on that line: `{ "a": 1 }` gains `, "b": 2` instead of being reformatted. ## Custom edits with `.modify()` [Section titled “Custom edits with .modify()”](#custom-edits-with-modify) For comments, or any edit the named ops do not cover, `.modify(fn)` hands `fn` the live `JsonDocument`. It has one property, `text`: the current source, without the BOM, which is added back when the file is printed. `jsonc-parser` edits text rather than a mutable tree, so you compute edits with it and assign the result back to `doc.text`. Named ops chained after `.modify()` see the new text. The dialect exports the complete `jsonc-parser` module as `astLibrary`, from the same entry point as `find`. You do not need to add `jsonc-parser` to your own dependencies. ### Example: enable a compiler option with a comment [Section titled “Example: enable a compiler option with a comment”](#example-enable-a-compiler-option-with-a-comment) This factory sets `compilerOptions.strict` with `modify` and `applyEdits`, then adds a comment above it. A re-run finds the comment and writes nothing new: tsconfig.json (before) ```json { // Shared compiler settings. "compilerOptions": { "target": "es2022" } } ``` factory.ts ```ts import { find, astLibrary } from "@pbuilder/sdk/json"; const COMMENT = "// Required by the build pipeline."; export default async () => { await find("tsconfig.json").modify((doc) => { const path = ["compilerOptions", "strict"]; const edits = astLibrary.modify(doc.text, path, true, { formattingOptions: { insertSpaces: true, tabSize: 2 }, }); doc.text = astLibrary.applyEdits(doc.text, edits); if (doc.text.includes(COMMENT)) return; const property = astLibrary.findNodeAtLocation(astLibrary.parseTree(doc.text)!, path)!.parent!; const lineStart = doc.text.lastIndexOf("\n", property.offset) + 1; const indent = doc.text.slice(lineStart, property.offset); doc.text = astLibrary.applyEdits(doc.text, [ { offset: lineStart, length: 0, content: `${indent}${COMMENT}\n` }, ]); }); }; ``` tsconfig.json (after) ```json { // Shared compiler settings. "compilerOptions": { "target": "es2022", // Required by the build pipeline. "strict": true } } ``` You own the edits Edits made in `.modify()` skip the named ops’ checks. `jsonc-parser` does not validate values or detect the document’s indentation for you: pass `formattingOptions` that match the file, and keep the text valid JSON. The [rules for custom edits](/guides/modify/#rules-for-custom-edits) apply here too. # Modify > Make AST-aware edits to TypeScript and TSX files with the built-in dialects, including idempotent imports, named ops, and the .modify() escape hatch. When string-level mutation isn’t enough (“add an import to this module”, “set a prop on this JSX element”), **dialects** provide structured, AST-aware operations for one file type. A dialect never edits text by search-and-replace: it parses the file into an AST, mutates the tree, and prints the result back. Each file type gets the AST library that fits it best. The `@pbuilder/sdk/typescript` and `@pbuilder/sdk/react` dialects covered on this page are both built on TypeScript’s own AST (surfaced through ts-morph), because TS and TSX share a grammar. JSON and YAML files have dialects of their own: see [JSON dialect](/guides/json-dialect/) and [YAML dialect](/guides/yaml-dialect/), and CSS files use the [CSS dialect](/guides/css-dialect/). The React dialect, the [NestJS dialect](/guides/nestjs-dialect/) and the [Angular dialect](/guides/angular-dialect/) add framework ops on top of the TypeScript ones. ## How a dialect handle works [Section titled “How a dialect handle works”](#how-a-dialect-handle-works) A dialect’s entry point is its own `find(path)`: it opens a **coalescing, awaitable handle**. Every op you chain mutates the same live AST, and the handle serializes to exactly **one** `replaceContent`-style directive when it flushes. ```ts import * as ts from "@pbuilder/sdk/typescript"; await ts.find("src/index.ts") .addImport("readFileSync", "node:fs") .addFunction("hi", "(): void {}", { export: true }); ``` A read (`.read()` on the handle, or any read on any path) drains the open directive first; a chain with one mid-chain read produces exactly two cumulative directives, and no edit is lost. The handle is a **thenable**, the one async departure from the SDK’s otherwise synchronous verbs. `await` is not required for correctness: the run boundary drains every outstanding handle before the run flushes, so a forgotten `await` still commits its edit, and a chain that throws still surfaces its error contained at the run boundary instead of as an unhandled rejection. `await` the chain yourself when you need to sequence a read after your own write, or to observe a failure locally in your own `try`/`catch`. ### Queries [Section titled “Queries”](#queries) Most ops write and return the handle, so they chain. A dialect can also declare queries, which read a value. A query ends the chain: `await` it and it resolves to the value. It sees every op chained before it on the same handle, writes nothing, and the ops before it still commit. A query that would change the file rejects and fails the run. The [CSS dialect](/guides/css-dialect/)’s `getDeclaration` is a query. ## The TypeScript dialect [Section titled “The TypeScript dialect”](#the-typescript-dialect) ```ts import * as ts from "@pbuilder/sdk/typescript"; await ts.find("src/index.ts") .addImport("readFileSync", "node:fs") .addFunction("hi", "(): void {}", { export: true }); ``` | Op | What it does | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `addImport(name, from, opts?)` | Adds `import { name } from "from"`, merging into an existing clause from the same module. Idempotent: calling it twice never duplicates the import. With `{ position: "sorted" }` it places the import in order instead of appending it. See [Sorted imports](#sorted-imports). | | `addDefaultImport(name, from)` | Adds `import name from "from"`, merging into a declaration of that module with no default yet. Rejects a second default under another name. | | `addNamespaceImport(name, from)` | Adds `import * as name from "from"`, merging into a default-only declaration. Beside named imports it opens its own declaration. | | `addTypeImport(name, from)` | Adds `import type { name } from "from"` as its own statement, never merged into a value import. A no-op when the module already imports `name`, by value or as a type. | | `addSideEffectImport(from)` | Adds `import "from"`. A no-op when that bare import already exists. | | `addReExport(name, from)` | Adds `export { name } from "from"`, merging into an existing re-export of that module. Rejects a name the file already exports. | | `addExportAll(from, { as? })` | Adds `export * from "from"`, or `export * as ns from "from"` with `{ as: "ns" }`. | | `removeImport(name, from)` | Removes the named binding; deletes the whole statement when it was the last one. Idempotent on an absent binding. | | `removeExport(name, from)` | Removes a named re-export; deletes the statement when it was the last one. Idempotent on an absent one. | | `removeExportAll(from, { as? })` | Removes `export * from "from"` (or the `as` form). Idempotent on an absent one. | | `sortImports()` | Sorts the file’s import declarations by module, and the names inside each one’s braces. Only reorders. A no-op on a sorted file. See [Sorted imports](#sorted-imports). | | `addFunction(name, source, opts?)` | Appends a top-level function. `source` **includes** the braces (`"(): void {}"`). | | `addVariable(name, initializer, opts?)` | Appends a top-level variable (`kind` defaults to `const`). | | `addClass(name, source, opts?)` | Appends a top-level class. `source` **excludes** the braces, and the op adds them. | | `addArrayElement(locator, element)` | Appends `element` to the array literal that `locator` points at, keeping the list’s layout. A no-op when the element is already there. See [Nested arrays](#nested-arrays-addarrayelement). | | `addClassProperty(className, name, initializer, opts?)` | Adds `name = initializer;` after the last property of the only class named `className`. A no-op when the same property exists. | | `.modify(fn)` | The universal escape hatch: direct [ts-morph](https://ts-morph.com) access to the file’s AST for anything the named ops don’t cover. | Note the deliberate contrast between the two `source` conventions: ```ts // addFunction: source INCLUDES braces await ts.find("src/index.ts").addFunction("hi", "(): void {}", { export: true }); // -> export function hi(): void {} // addClass: source EXCLUDES braces (the op adds them) await ts.find("src/index.ts").addClass("Point", " x = 0;"); // -> class Point {\n x = 0;\n} ``` `addVariable` emits `{export }{kind} {name} = {initializer};`. `kind` accepts `"const"` (default), `"let"`, or `"var"`, and rejects any other value: ```ts await ts.find("src/index.ts").addVariable("counter", "0", { export: true, kind: "let" }); // -> export let counter = 0; ``` ### Import forms and barrel files [Section titled “Import forms and barrel files”](#import-forms-and-barrel-files) Each import form has its own op. Running one twice changes the file once: ```ts await ts .find("src/main.ts") .addDefaultImport("express", "express") .addTypeImport("Request", "express") .addSideEffectImport("reflect-metadata"); // -> import express from "express"; // -> import type { Request } from "express"; // -> import "reflect-metadata"; ``` The re-export ops build barrel files. Generate a file, then export it from `index.ts`: ```ts import { create } from "@pbuilder/sdk/commons"; import * as ts from "@pbuilder/sdk/typescript"; create("src/user.service.ts", { template: "export class UserService {}", options: {} }); await ts .find("src/index.ts") .addReExport("UserService", "./user.service") .addExportAll("./dto"); // -> export { UserService } from "./user.service"; // -> export * from "./dto"; ``` `addReExport` and `addExportAll` reject a name the file already exports by any route (another re-export, an `export * as`, or an exported declaration), because two exports sharing a name are invalid TypeScript. `addReExport` and `removeExport` match by source name, so `export { name as Y }` already counts. `removeExport` never touches a local `export { x }` or an `export *`, and each `export *` form only removes itself. **Name validation.** Every op checks `name` before it touches the file. It must be a plain ASCII JavaScript identifier (`^[A-Za-z_$][A-Za-z0-9_$]*$`), not a reserved or strict-mode-restricted word (`class`, `await`, `eval`, `arguments`, …), and not `__proto__`, `constructor`, or `prototype`. A rejected name leaves the file unchanged, and `removeImport` rejects it even when there was nothing to remove. **Collision rules.** The `add*` ops fail loud on a name collision with an existing value declaration or import binding, because two value declarations sharing a name is invalid TypeScript. An import binding is any import form: named, default (`import X from "m"`) and namespace (`import * as X from "m"`), type-only ones included. A `type`/`interface` sharing the name does **not** collide (TypeScript legally permits a value and a type to share an identifier). Verbatim source strings `source`/`initializer` strings are inserted **verbatim**, never validated or sanitized, so the author owns their syntax, and anything derived from untrusted input (schema options, CLI answers, network data) is the author’s responsibility to sanitize. ### Sorted imports [Section titled “Sorted imports”](#sorted-imports) `addImport` appends by default. Pass `{ position: "sorted" }` to keep a file’s imports in order: the name goes into its alphabetical place inside the braces, and a new declaration goes next to its folder siblings, before the first of them that sorts after it. ```ts // import { BookOpen, Users } from "lucide-react"; // import { authorHandlers } from "./domains/authors.mock"; // import { config } from "./config"; await ts.find("src/mocks/handlers.ts") .addImport("LayoutDashboard", "lucide-react", { position: "sorted" }) .addImport("bookHandlers", "./domains/books.mock", { position: "sorted" }); // import { BookOpen, LayoutDashboard, Users } from "lucide-react"; // import { authorHandlers } from "./domains/authors.mock"; // import { bookHandlers } from "./domains/books.mock"; // import { config } from "./config"; ``` `sortImports()` sorts the whole file instead: its import declarations by module, and the names inside each declaration’s braces, in the same order. A multi-line brace layout is kept. Both follow the same rules: * Sorting is case-insensitive, and package specifiers (`react`, `node:fs`, `@/lib`) sort ahead of relative ones. * A module’s folder siblings are the existing imports that share the most leading path segments with `from`. In a file with no imports, the declaration goes where the unsorted form puts it. * Sorted `addImport` never moves existing imports, so a file that was out of order stays out of order around the new line. A new declaration goes above a comment attached to the import it precedes. * Without the option, `addImport` behaves as before. Any other `position` value rejects and leaves the file unchanged. * Inserted imports match the file’s quotes and semicolons, like every insertion. * `sortImports` only reorders. It never combines two declarations of one module, removes an unused import, or reformats one, and on a sorted file it writes nothing. * Declarations move only within a run of consecutive imports. A blank line, a comment, a side-effect import (`import "./polyfills";`, whose position changes behaviour), or a declaration with a comment attached ends the run, and nothing crosses it. Braces that contain a comment keep their order. ### Nested arrays: `addArrayElement` [Section titled “Nested arrays: addArrayElement”](#nested-arrays-addarrayelement) Most generators create a file and then register it somewhere: a provider in `appConfig`, a guard on a route, a module in `@NgModule`. `addArrayElement(locator, element)` makes that edit. The `locator` names a root and then a `path` from that root to the array: | Root | Starts at | | ----------------------------------------------------- | -------------------------------------------------------- | | `{ variable: "appConfig", path }` | The initializer of that top-level variable | | `{ call: "bootstrapApplication", argument: 1, path }` | That argument of the only call to `bootstrapApplication` | | `{ decorator: "NgModule", path }` | The argument of the only `@NgModule(...)` | Each `path` segment is an object key (`"providers"`) or a match such as `{ path: "admin" }`, which selects the only array element whose `path` property is `"admin"`. There are no numeric indices, because an index points at the wrong element once someone reorders the array. At each step the op looks through `as`, `satisfies`, `` assertions, `!` and parentheses. ```ts await ts .find("src/app/app.config.ts") .addImport("provideHttpClient", "@angular/common/http") .addArrayElement({ variable: "appConfig", path: ["providers"] }, { expression: "provideHttpClient()" }); // before: providers: [provideRouter(routes)], // after: providers: [provideRouter(routes), provideHttpClient()], ``` A match walks into one entry of a list. Here the `admin` route has no `providers` key yet, so the op creates it: ```ts await ts .find("src/app/app.routes.ts") .addImport("AdminGuard", "./admin/admin.guard") .addArrayElement({ variable: "routes", path: [{ path: "admin" }, "providers"] }, "AdminGuard"); ``` ```ts // before export const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, }, ]; // after export const routes: Routes = [ { path: "", component: HomeComponent }, { path: "admin", component: AdminComponent, providers: [AdminGuard], }, ]; ``` The op creates a missing key (`{}` becomes `{ providers: [X] }`) and the next missing argument of a call or decorator (`bootstrapApplication(App)` becomes `bootstrapApplication(App, { providers: [X] })`). It rejects, and leaves the file unchanged, when: * the root does not exist, or a match selects nothing; * the root or a match selects more than one node; * the argument is past the next one, such as `argument: 2` on `bootstrapApplication(App)`; * the missing key could already come from a spread (`{ ...base }`), a computed key, or a getter or setter, since a new key might shadow it. `element` is an identifier, or `{ expression: "..." }` for code. The expression must parse as exactly one expression, so it cannot close the array or add a statement. The op inserts it verbatim otherwise, so never build it from consumer input. An element that is already in the array is a no-op: elements are compared token by token, and spacing, comments and trailing commas don’t count. The new element follows the list’s layout. A one-line list stays on one line, a multi-line list gets a new line at the last entry’s indentation, and a trailing comma stays. The op adds no import, so pair it with `addImport`. ### Class properties: `addClassProperty` [Section titled “Class properties: addClassProperty”](#class-properties-addclassproperty) `addClassProperty(className, name, initializer, options?)` writes `{scope }{readonly }name = initializer;` into an existing class. `options` is `{ scope?: "public" | "protected" | "private"; readonly?: boolean }`, and an omitted option writes nothing. ```ts await ts .find("src/app/user.service.ts") .addImport("inject", "@angular/core") .addClassProperty("UserService", "http", "inject(HttpClient)", { scope: "private", readonly: true }); ``` ```ts // before export class UserService { private readonly cache = new Map(); load(id: string) { return this.cache.get(id); } } // after export class UserService { private readonly cache = new Map(); private readonly http = inject(HttpClient); load(id: string) { return this.cache.get(id); } } ``` The property goes after the class’s last property, which keeps properties above the constructor and methods. A class without properties gets it as its first member. The file must have exactly one class named `className`. A property with the same declaration is a no-op, and any other member named `name`, a method included, rejects. `initializer` must parse as one expression and is inserted verbatim, like `{ expression }`. The op adds no import. ## The React dialect [Section titled “The React dialect”](#the-react-dialect) `@pbuilder/sdk/react` mutates `.tsx` and `.jsx` files, and `find()` requires one of those two extensions on the path. An extensionless path is rejected, never normalized, and any other extension rejects with `.tsx and .jsx are the only extensions the React dialect supports`; a `.ts` path points you to `@pbuilder/sdk/typescript`. The handle carries every [TypeScript dialect](#the-typescript-dialect) op (`addImport`, `addDefaultImport`, `sortImports` and the rest) plus JSX and component ops, with `.modify(fn)` as the escape hatch for everything else: ```ts import * as react from "@pbuilder/sdk/react"; // src/Button.tsx before: const el = await react .find("src/Toolbar.tsx") .addImport("PrimaryButton", "./ui") .renameJsxElement("Button", "PrimaryButton"); // -> Save ``` None of these ops adds an import, so pair them with `addImport`. ### Component ops [Section titled “Component ops”](#component-ops) `addHookCall` and `addComponentProp` take a `component` name. The op looks for it among the file’s top-level `function Name` declarations, exported or not, and top-level `const Name =` arrow functions or function expressions. A wrapped component (`memo(...)`, `forwardRef(...)`) is not found, and zero or several matches reject. `addHookCall(component, statement)` inserts `statement` after the component’s leading hook calls (statements that call `useX(...)` or `React.useX(...)`), or first when there are none, at the body’s indentation. `statement` must be exactly one statement. An identical statement already in the body is a no-op, and an expression-bodied arrow (`() =>
`) rejects because it has no statements to add to: ```ts // src/Form.tsx before: // export function Form() { // const [name, setName] = useState(""); // return
; // } await react.find("src/Form.tsx").addHookCall("Form", "const t = useTranslation();"); // -> export function Form() { // -> const [name, setName] = useState(""); // -> const t = useTranslation(); // -> return ; // -> } ``` `addComponentProp(component, name, type, options?)` adds `name: type`, or `name?: type` with `{ optional: true }`, to the component’s props type. When the props parameter is destructured, it also adds `name` to the destructuring, before a `...rest` element. The props type is the first parameter’s inline type literal, or an `interface` or `type` literal declared in the same file and referenced by name. An imported or untyped props type rejects. A component without parameters gets `({ name }: { name: type })`. The same name with the same type text is a no-op, and a different type rejects. `type` must be exactly one type: ```ts // src/Card.tsx before: // interface CardProps { // title: string; // } // export function Card({ title }: CardProps) { ... } await react.find("src/Card.tsx").addComponentProp("Card", "subtitle", "string", { optional: true }); // -> interface CardProps { // -> title: string; // -> subtitle?: string; // -> } // -> export function Card({ title, subtitle }: CardProps) { ... } ``` Two React-specific subtleties: * **Spread precedence.** An inserted prop lands after a trailing `{...spread}`, so it **wins** at runtime under React’s later-position precedence: `