Architecture guide for the dAppBooster installer.
| Category | Technology | Notes |
|---|---|---|
| Framework | React + Ink | Terminal UI for interactive mode |
| Language | TypeScript (strict mode) | Extends @sindresorhus/tsconfig. tsconfig.json builds source/; tsconfig.tests.json typechecks the tests |
| Arg parsing | meow | CLI flag parsing, non-interactive mode |
| Styling | Ink primitives | <Box>, <Text>, ink-gradient, ink-big-text, ink-spinner |
| Testing | Vitest + @vitest/coverage-v8 | |
| Lint + format | Biome 2 | biome.json; one tool for both |
| Dead code | knip | knip.json; the entry points are cli.tsx and the test files |
| Node | v24.15.0+ published, 24 for development | engines.node is the floor, matched to what dAppBooster needs; CI uses .nvmrc |
source/
cli.tsx Entry point: meow arg parsing, mode routing
app.tsx Interactive TUI: step-based state machine
nonInteractive.ts Non-interactive: validate flags → run operations → JSON
info.ts --info JSON output for agent discovery
config.ts Feature definitions, prepare and staging paths, the repository to clone, the package manager
operations/
exec.ts exec (shell) and execFile (no shell) helpers
cloneRepo.ts Check pnpm, clone at the newest tag or at DAPPBOOSTER_REF, rm .git, git init
createEnvFile.ts Copy .env.example to .env.local
installPackages.ts pnpm install, or pnpm remove for the features left out
cleanupFiles.ts Removes the prepare paths and the features left out, patches package.json, all before the install
installGuard.ts Removes the partial project dir if interrupted mid-scaffold
index.ts Barrel export
components/
steps/ TUI step components (presentation-only)
ProjectName.tsx First step: prompt for the project name
InstallationMode.tsx Mode selection: full or custom
OptionalPackages.tsx Feature multiselect (custom mode only)
Confirmation.tsx Review step, the last one before any disk work
CloneRepo/CloneRepo.tsx Clone progress display
FileCleanup.tsx Cleanup progress display, runs before the install
Install/Install.tsx Env file and package install
StepProgress.tsx Shared runner for the operation steps: progress, errors, guard
PostInstall.tsx Closing screen, including the subgraph warning
Ask.tsx Text input with validation
Divider.tsx Section divider
MainTitle.tsx Gradient title banner
Multiselect/ Checkbox multiselect component
types/
types.ts Shared TypeScript types
utils/
utils.ts Feature and mode helpers, validation, path helpers
__tests__/ Mirrors the source/ layout
Everything the installer knows about the dAppBooster template:
featureDefinitions: one entry per optional feature.featureEntries,featureNamesanddefaultFeatureNamescome from it.isFeatureNameValid(name): a type guard, so validated CLI input narrows fromstringtoFeatureName.prepare: paths every scaffold drops, whatever the user picked. These are the template's own repo files (.github,.claude, the agent docs).staging: the template's.install-filesdirectory, which holds the replacement files a deselected feature restores. Cleanup removes it last.getRepo(): the repository URL and an optionalref. Without aref, the installer clones the newest tag. The env varsDAPPBOOSTER_REPO_URLandDAPPBOOSTER_REFoverride both, for forks and pre-release testing.packageManager:'pnpm', the package managercloneRepochecks for andinstallPackagesruns.
FeatureName is the union of every feature name, declared in source/types/types.ts. featureDefinitions has the type Record<FeatureName, FeatureDefinition>, so the union and the map cannot drift apart without a compile error. Renaming a feature therefore turns every stale 'oldName' string in the codebase into a compile error.
Installation modes are full (keep every feature) and custom (pick them).
Shape:
type FeatureDefinition = {
description: string // --info output
label: string // TUI multiselect display
packages: string[] // dependencies the package manager removes when the feature is deselected
default: boolean // --info output, and pre-checked in the multiselect
postInstall?: string[] // post-install steps for the JSON output; the wizard also prints subgraph's
paths?: string[] // files/dirs removed when the feature is deselected
scripts?: string[] // package.json scripts removed when the feature is deselected
}To add a new feature, add its name to the FeatureName union in source/types/types.ts and an entry to featureDefinitions. Programmatic consumers pick it up on their own. Feature cleanup reads paths and scripts, and the install reads packages (see the operations layer below), so a new feature usually needs no code. The one exception is demo and subgraph, which restore replacement source files from .install-files. The CLI --help text in cli.tsx keeps its own copy either way.
The package manager always removes packages (pnpm remove), which updates package.json and the lockfile together. Nothing edits dependencies by hand.
resolveModeFeatures(mode, customSelection) in utils.ts maps a mode to its kept features, in config order. Full mode keeps all of them, and custom mode keeps the selection. The non-interactive path resolves it in validate. The interactive path resolves it once in app.tsx and passes the result to every step, so the review screen lists exactly what gets installed.
Plain async functions, with no UI dependencies. Multi-step operations accept an optional onProgress callback for the TUI; the non-interactive path leaves it out.
| Function | What it does |
|---|---|
cloneRepo(projectName, onProgress?) |
Fails first when pnpm is not installed, before anything touches the disk. Then it clones. Without a ref, it runs a shallow clone with --no-checkout, then git fetch --tags, then git checkout $(git describe --tags …), which needs a shell for $(). When you set DAPPBOOSTER_REF, that tag or branch wins over the latest tag: git fetch --depth 1 origin <ref>, then git checkout FETCH_HEAD, with no shell. Then it removes .git and runs git init. It uses execFile everywhere except the latest-tag shell substitution. |
createEnvFile(projectFolder) |
Copies .env.example to .env.local. |
installPackages(projectFolder, features, onProgress?) |
With no packages to remove, it runs pnpm install. Otherwise it runs pnpm remove, which prunes the manifest and the lockfile together, then pnpm run --if-present postinstall. It runs after cleanupFiles, so it resolves the pruned manifest once. It uses execFile only, never a shell. |
cleanupFiles(projectFolder, features, onProgress?) |
Reads the config and runs before the install. First it removes the prepare paths, which every scaffold drops. Then it loops over the features. For each one the user left out, it removes the feature's paths and collects its scripts. It restores the demo-free home page from .install-files when the user drops demo or subgraph, and removes the collected scripts from package.json in a single pass, writing the file only when a value changed. It leaves feature dependencies to installPackages. The staging directory goes last, once the restores no longer need it. |
source/operations/installGuard.ts makes sure a Ctrl+C or a failure mid-scaffold leaves no partial directory behind.
beginInstall(projectFolder)runs the moment disk work starts, beforecloneRepo. It registers theSIGINTandSIGTERMhandlers.completeInstall()runs once the install finishes.abortInstall()runs when an operation throws. It removes the partial directory and setsprocess.exitCode = 1, so a failed interactive run reports failure to the shell instead of exiting 0.
The three interactive operation steps (CloneRepo, FileCleanup, Install) all call abortInstall through StepProgress. On an interrupt during a scaffold, the handler removes the project directory. After completeInstall it does nothing, so it never deletes a finished project, even on a Ctrl+C on the post-install screen. It only removes a directory this run created, because both entry paths reject a directory that already exists. It never touches user data.
Both paths use it. The non-interactive runner wraps its operations with it. In the wizard, CloneRepo calls beginInstall, and Install, the last operation step, calls completeInstall.
Two helpers with different security profiles:
execFile(file, args, options)wrapschild_process.spawnwithout a shell. It passes the arguments as an array, so the shell never reads user input as metacharacters. Use it whenever a user-provided value, such asprojectName, appears in the command.exec(command, options)wrapschild_process.spawnto run/bin/sh -c <command>, which starts a shell. Use it only for commands that need shell features like$(...)substitution. Never interpolate user input into the command string.
Both helpers call spawn with stdout ignored and stderr piped. They do not capture or return stdout, and they buffer no output for the caller. On a non-zero exit code they throw with the stderr message, or with the signal name when a signal kills the process.
- The installer validates user input (
projectName) against/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/before any use. The first character cannot be a dash, or git would read the name as an option rather than a directory. - Operations use
execFile(no shell) for commands that include user input or config values. - Only the latest-tag checkout (
git checkout $(git describe …)) usesexec(shell), and its command string never includes user input. - The repository URL and ref may come from the environment (
DAPPBOOSTER_REPO_URL,DAPPBOOSTER_REF).execFilepasses them to git as arguments, never inside a shell string. - The helpers ignore child process stdout and pipe stderr, which they keep only for error messages. That keeps the parent's stdout clean for JSON.
CLI flags (string)
→ meow parses to typed flags
→ validate() converts to { name, mode, features: FeatureName[] }
→ operations receive typed args
→ JSON output to stdout
Routing: source/cli.tsx
unknown flag → JSON error → exit 1
--info → source/info.ts → print JSON → exit 0
--ni / !isTTY → source/nonInteractive.ts → validate → operations → JSON
default → dynamic import ink + App → TUI
Non-interactive validation order:
--namemust be present--namematches/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/. A leading dash fails, because git would read the name as an option--modemust be present--modeisfullorcustom- Full mode: skip to step 7 (it keeps every feature and ignores
--features) - Custom mode:
--featuresmust be present, must parse to a non-empty list (it rejects trailing commas and whitespace-only entries), and every name must be valid - The project directory must not already exist
Non-interactive execution order:
cloneRepo, then cleanupFiles, then createEnvFile, then installPackages, then the success JSON.
Cleanup runs before the install on purpose. It prunes package.json, so the package manager resolves the pruned manifest once and writes a lockfile that matches. The reverse order left the generated project with a lockfile listing packages its package.json no longer had, which fails a frozen-lockfile install.
Any error produces { "success": false, "error": "..." } and exit code 1. reportFailure is the only place that prints it. It sets process.exitCode = 1 and returns a ReportedError, which fail() throws. cli.tsx ignores that error type and reports anything else. Nothing calls process.exit() directly, so stdout flushes before the process ends when piped.
Success output:
{
"success": true,
"projectName": "...",
"mode": "full|custom",
"features": ["..."],
"path": "/absolute/path",
"postInstall": ["..."]
}In full mode, features lists every feature name. In custom mode it lists the selected ones, in config order.
User input via Ink components
→ useState in App.tsx (projectName, setupType, selectedFeatures)
→ passed as props to step components
→ components convert MultiSelectItem[] → FeatureName[]
→ operations receive typed args
→ Ink renders progress/status
All questions come before any disk work, as in the non-interactive path. Leaving the wizard while answering leaves nothing behind.
Questions (no disk): ProjectName → InstallationMode → OptionalPackages (custom only) → Confirmation
Operations (disk): CloneRepo → FileCleanup → Install → PostInstall
Each operation step renders through the shared StepProgress component. It owns the step list, the running, done and error display, and the failure path (abortInstall), so every step reports a failure the same way. Install covers the env file and the package install, and calls completeInstall when the scaffold finishes.
Confirmation shows the plan as one line per setting (describeInstallPlan), each value highlighted. It is the last step with no side effects. Yes starts the operations. No goes back to the first question: the wizard resets its state and re-keys the question steps so they mount fresh.
Components are presentation-only. They call operations from useEffect and render the status. They receive MultiSelectItem[] for the feature selection, a TUI concern, then derive the kept FeatureName[] with resolveModeFeatures(mode, selected) before calling operations. That way full mode resolves correctly even though the wizard skips the multiselect. The OptionalPackages multiselect pre-checks the default: true features. When the user keeps the subgraph feature, PostInstall adds a warning that lists the steps from its postInstall lines.
source/types/types.ts: add the name to theFeatureNameunion.source/config.ts: add an entry tofeatureDefinitions. Leave one out and the file does not compile.default: truepre-checks the feature in the custom-mode multiselect. List the feature'spaths,scriptsandpackages. Cleanup and the install read all three, so you need no new code.source/operations/cleanupFiles.ts: only needed when the feature has to put a replacement file back, the waydemoandsubgraphcopy from.install-files.- Post-install: add
postInstalllines for the JSON output, and extendPostInstall.tsxif the wizard needs to say more. source/cli.tsx: update the--helptext.- Tests: add assertions in the relevant test files. The nonInteractive, info, installPackages, and utils tests pick up new features from
featureDefinitions. - Verify:
pnpm build && pnpm lint && pnpm test.
- Create
source/operations/newOperation.tsand export an async function from it. UseexecFilefor commands with user input, andexeconly when you need shell features. - Export it from
source/operations/index.ts. - Call it from
source/nonInteractive.ts, in the execution sequence, and from the relevant TUI component. - Add tests in
source/__tests__/operations/newOperation.test.ts. MockexecandexecFileto check the commands.