| Category | Technology | Notes |
|---|---|---|
| Framework | React + Ink | Terminal UI for interactive mode |
| Language | TypeScript (strict mode) | Extends @sindresorhus/tsconfig |
| Arg parsing | meow | CLI flag parsing, non-interactive mode |
| Styling | Ink primitives | <Box>, <Text>, ink-gradient, ink-big-text |
| Testing | Vitest + @vitest/coverage-v8 | |
| Node | v20+ | See .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
constants/
config.ts Single source of truth: feature definitions, repo URL
operations/
exec.ts exec (shell) and execFile (no shell) helpers
cloneRepo.ts Shallow clone, checkout latest tag, reinit git
createEnvFile.ts Copy .env.example → .env.local
installPackages.ts pnpm install / remove based on mode and features
cleanupFiles.ts Remove files for deselected features, patch package.json
index.ts Barrel export
components/
steps/ TUI step components (presentation-only)
ProjectName.tsx Prompt for project name
CloneRepo/CloneRepo.tsx Clone progress display
InstallationMode.tsx Full / Custom selection
OptionalPackages.tsx Feature multiselect
Install/Install.tsx Install progress display
FileCleanup.tsx Cleanup progress display
PostInstall.tsx Post-install instructions
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 Validation, path helpers, package resolution
__tests__/ Mirrors source/ layout
nonInteractive.test.ts
info.test.ts
utils.test.ts
operations/
exec.test.ts
cloneRepo.test.ts
createEnvFile.test.ts
installPackages.test.ts
cleanupFiles.test.ts
Single source of truth for feature metadata. All programmatic consumers (--info, validation, TUI multiselect, operations) read from here. CLI --help text maintains its own copy.
featureDefinitions: Record<FeatureName, {
description: string // --info output
label: string // TUI multiselect display
packages: string[] // pnpm packages to remove when deselected
default: boolean // --info output
postInstall?: string[] // post-install instructions for non-interactive JSON output
}>featureNames is derived as Object.keys(featureDefinitions).
When adding a new feature, add it here. Programmatic consumers (validation, info output, TUI selection) pick it up automatically — except cleanupFiles.ts (which needs explicit cleanup rules) and the CLI --help text in cli.tsx (which maintains its own copy).
Plain async functions with no UI dependencies. Each operation receives explicit arguments (project folder, mode, features) and performs file system or shell work. Multi-step operations accept an optional onProgress callback that the TUI uses to render per-step progress; the non-interactive path omits it.
| Function | What it does |
|---|---|
cloneRepo(projectName, onProgress?) |
Shallow clone, fetch tags, checkout latest tag, rm .git, git init. Uses execFile (no shell) for git commands except git checkout $(...) which needs shell substitution. Uses fs.rm for .git removal. |
createEnvFile(projectFolder) |
Copy .env.example to .env.local via fs.copyFile |
installPackages(projectFolder, mode, features, onProgress?) |
Full: pnpm i. Custom with packages to remove: pnpm remove + postinstall. Custom with all features: pnpm i. Uses execFile exclusively (no shell). |
cleanupFiles(projectFolder, mode, features, onProgress?) |
Remove files/folders for deselected features, patch package.json scripts, remove .install-files. Uses node:fs/promises (rm, mkdir, copyFile) for async operations; patchPackageJson uses sync node:fs. |
Two helpers with different security profiles:
execFile(file, args, options)— wrapschild_process.spawnwithout a shell. Arguments are passed as an array, so user input cannot be interpreted as shell metacharacters. Use this whenever user-provided values (e.g.,projectName) appear in the command.exec(command, options)— wrapschild_process.spawnto run/bin/sh -c <command>(spawns a shell). Only for commands that require shell features like$(...)substitution. Never interpolate user input into the command string.
Both helpers use spawn with stdout ignored and stderr piped. They do not capture or return stdout — output is not buffered for the caller. They throw on non-zero exit codes with the stderr message, or report the signal name when the process is killed by a signal.
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
--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:
--namerequired--moderequired--namematches/^[a-zA-Z0-9_]+$/--modeisfullorcustom- Full mode: skip to step 9 (features ignored, all installed)
--featuresrequired for custom mode- Parsed features list is non-empty (rejects trailing commas, whitespace-only entries)
- All feature names are valid keys in
featureDefinitions - Project directory does not already exist
Non-interactive execution order:
cloneRepo → createEnvFile → installPackages → cleanupFiles → success JSON
Any error produces { "success": false, "error": "..." } and exit code 1. Errors set process.exitCode = 1 and throw rather than calling process.exit() directly, ensuring stdout flushes before the process terminates when piped.
Success output:
{
"success": true,
"projectName": "...",
"mode": "full|custom",
"features": ["..."],
"path": "/absolute/path",
"postInstall": ["..."]
}For full mode, features lists all feature names. For custom mode, only the selected ones.
User input via Ink components
→ useState in App.tsx
→ passed as props to step components
→ components convert MultiSelectItem[] → FeatureName[]
→ operations receive typed args
→ Ink renders progress/status
Steps: ProjectName → CloneRepo → InstallationMode → OptionalPackages → Install → FileCleanup → PostInstall
Components are presentation-only — they call operations via useEffect and render status. Components receive MultiSelectItem[] for feature selection (TUI concern) and convert to FeatureName[] before calling operations.
-
source/constants/config.ts— add entry tofeatureDefinitionswith description, label, packages, default, and optional postInstall. Add the name to theFeatureNameunion type. -
source/operations/cleanupFiles.ts— add a cleanup function and call it fromcleanupFiles()when the feature is deselected. If the feature has scripts in package.json, add removal topatchPackageJson. -
source/components/steps/PostInstall.tsx— if the feature has post-install instructions, add TUI rendering here. The component hardcodes its own display (richer than thepostInstallstrings in config), so new features with post-install steps need manual JSX. -
source/cli.tsx— update the--helptext to include the new feature name and description. -
Tests — add test cases in
source/__tests__/operations/cleanupFiles.test.tsfor the new cleanup rules. The nonInteractive, info, installPackages, and utils tests pick up new features automatically since they read fromfeatureDefinitions. -
Verify —
pnpm build && pnpm lint && pnpm test
Steps 1 and 6 are always required. Steps 2-5 depend on whether the feature has cleanup rules, post-install instructions, or descriptions for --help.
-
Create
source/operations/newOperation.ts— export an async function. UseexecFilefor commands with user input,execonly when shell features are needed. -
Export from
source/operations/index.ts. -
Call from
source/nonInteractive.ts(in the execution sequence) and from the relevant TUI component. -
Add tests in
source/__tests__/operations/newOperation.test.ts— mockexec/execFileto verify correct commands.
- User input (
projectName) is validated against/^[a-zA-Z0-9_]+$/before any use - Operations use
execFile(no shell) for commands that include user input exec(shell) is reserved for commands needing shell substitution, and never receives user input in the command string- Child process stdout is ignored and stderr is piped (captured for error diagnostics only), guaranteeing clean JSON on the parent's stdout