diff --git a/AGENTS.md b/AGENTS.md index 7f0d014..682fd09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ -# Agent Configuration +# Agent configuration This project's agent configuration lives in [`CLAUDE.md`](./CLAUDE.md). -Claude Code reads `CLAUDE.md` natively. If your agent reads `AGENTS.md` (Cursor, Windsurf, etc.), load rules from `CLAUDE.md` -- it is the single source of truth for this project's conventions, stack, and working rules. +Claude Code reads `CLAUDE.md` natively. If your agent reads `AGENTS.md` (Cursor, Windsurf, etc.), load rules from `CLAUDE.md`. It is the single source of truth for this project's conventions, stack, and working rules. diff --git a/CLAUDE.md b/CLAUDE.md index 658ae8e..14166b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,20 +1,19 @@ -# Agent Configuration +# Agent configuration > Claude Code reads this file natively. Other agents (Cursor, Windsurf, etc.) read `AGENTS.md`, which points here. This file is the single source of truth for the project's conventions, stack, and working rules. --- -## What This Is +## What this is -A CLI installer tool for dAppBooster projects. It supports two **stacks** and two **modes**: +A CLI installer for [dAppBooster](https://github.com/BootNodeDev/dAppBooster), the dApp boilerplate for EVM chains. Canton has its own installer, [`create-canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/tree/main/canton-create). This installer has two modes: -- **Stacks:** `evm` (the original dAppBooster for EVM chains) and `canton` (dAppBooster for Canton: Daml ledger, off-chain services). Each stack declares its own source repository, an optional `ref` to clone (without it the newest tag wins), package manager, env files, an optional `prepare` step, and features. Only EVM has features; Canton has none, so it takes neither `--mode` nor `--features`, and the wizard asks it nothing but the project name. -- **Interactive** (default): React + Ink TUI that prompts for the project name first, then the stack, then — for a stack that has features — the installation mode, the optional packages and a review step, then clone → cleanup → install → post-install. The stack prompt is skipped when `--canton`, `--evm`, or `--stack` is supplied. -- **Non-interactive**: Flag-driven (`--ni` or auto-detected when not a TTY) for AI agents and CI. Outputs JSON to stdout. Run `--info` for stack + feature discovery, then `--canton`/`--evm` (or `--stack`) + `--name`, plus `--mode` [+ `--features`] when the stack's `modes` list is not empty. Omitting a stack flag in non-interactive mode defaults to `evm` for backward compatibility. +- **Interactive** (default): a React + Ink TUI. It asks for the project name, the installation mode, the optional packages and a final review. Then it clones the template, cleans it up, installs the packages and shows the post-install steps. +- **Non-interactive**: driven by flags, for AI agents and CI. `--ni` turns it on, and so does a stdout that is not a TTY. It prints JSON to stdout. Run `--info` to discover the features, then pass `--name` and `--mode`, plus `--features` in custom mode. -## Stack & Conventions +## Stack and conventions | Category | Technology | Notes | |----------|-----------|-------| @@ -26,44 +25,43 @@ A CLI installer tool for dAppBooster projects. It supports two **stacks** and tw | Testing | Vitest + @vitest/coverage-v8 | | | Dead code | knip | `pnpm knip` fails on unused files, exports, and dependencies | | Secret scanning | gitleaks | Pinned in `.gitleaks-version`; the hooks install it into `bin/` | -| Git hooks | husky + lint-staged + commitlint | Installed by `pnpm i`; see [Git hooks](#git-hooks) | -| Node | v24.15.0+ | `engines.node` is the published floor, matched to what the scaffolded stacks need; `.nvmrc` (24) is what CI and development use | +| Git hooks | Husky + lint-staged + commitlint | `pnpm i` installs them; see [Git hooks](#git-hooks) | +| Node | v24.15.0+ | `engines.node` is the published floor, matched to what dAppBooster needs; CI and development use `.nvmrc` (24) | | Naming | camelCase vars/functions, PascalCase components/types | Biome enforces the same two cases for filenames | -## Code Style +## Code style -- **Semicolons:** as needed (Biome `asNeeded` — omitted unless required by ASI) +- **Semicolons:** as needed (Biome `asNeeded` leaves them out unless ASI requires one) - **Quotes:** single - **Print width:** 100 - **Trailing commas:** all (Biome default) - **Indent:** spaces, width 2 - **Imports:** explicit `.js` extensions (ESM, `"type": "module"`) -## Working Rules +## Working rules -- Use **pnpm** only (never npm or yarn), for this installer and for both scaffolded projects -- Treat `dist/` as build output — never edit directly -- User input (`projectName`) must never be interpolated into shell command strings — use `execFile` (args array) instead -- `source/stacks/` is the single source of truth for stack and feature metadata: one module per stack, with `source/stacks/index.ts` holding the record and the accessors. All programmatic consumers read it through `getStackConfig(stack)`. CLI `--help` text maintains its own copy. -- No file outside `source/stacks/` and `source/cli.tsx` may test a stack by name. Add a `StackConfig` field instead. -- Stack overrides come from env vars `DAPPBOOSTER__REPO_URL` and `DAPPBOOSTER__REF` (read inside `getStackConfig`) — useful for forks and pre-release testing. -- Components are presentation-only — business logic lives in `source/operations/`. Every operation that varies per stack takes `stack` as its first argument. +- Use **pnpm** only (never npm or yarn), for this installer and for the scaffolded project +- Treat `dist/` as build output. Never edit it directly +- Never interpolate user input (`projectName`) into shell command strings. Use `execFile` (args array) instead +- `source/config.ts` is the single source of truth for what the installer knows about the template: the features, the paths cleanup removes, the repository to clone and the package manager. CLI `--help` text maintains its own copy. +- The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override the repository and ref to clone. `getRepo()` reads them. Use them for forks and pre-release testing. +- Components are presentation-only. Business logic lives in `source/operations/`. ## Architecture See [architecture.md](./architecture.md) for the full architecture guide, including data flow, how to add features, and security patterns. -Entry: `source/cli.tsx` — parses args with `meow`, routes between interactive and non-interactive paths. +Entry: `source/cli.tsx` parses args with `meow` and routes between the interactive and non-interactive paths. -- **Interactive path**: `source/app.tsx` — step-based state machine that renders each installer step in sequence via React + Ink -- **Non-interactive path**: `source/nonInteractive.ts` — validates flags, runs operations sequentially, outputs JSON +- **Interactive path**: `source/app.tsx`, a step-based state machine that renders each installer step in sequence with React + Ink +- **Non-interactive path**: `source/nonInteractive.ts` validates flags, runs the operations one after another, and prints JSON Key directories: -- `source/operations/` — business logic as plain async functions, shared by both paths -- `source/components/steps/` — TUI step components, presentation-only -- `source/components/` — reusable UI components (Ask, Divider, MainTitle, Multiselect) -- `source/__tests__/` — vitest test suite +- `source/operations/`: business logic as plain async functions, shared by both paths +- `source/components/steps/`: TUI step components, presentation-only +- `source/components/`: reusable UI components (Ask, Divider, MainTitle, Multiselect) +- `source/__tests__/`: the Vitest suite ## Commands @@ -72,14 +70,14 @@ Key directories: | `pnpm build` | Compile `source/` to `dist/` | | `pnpm dev` | The same, in watch mode | | `pnpm typecheck` | Types only, no output. Two passes: `source/` for the build, then the tests | -| `pnpm test` | Run the vitest suite | +| `pnpm test` | Run the Vitest suite | | `pnpm test:coverage` | The same, with a coverage report | | `pnpm lint` | Biome check, warnings included | | `pnpm lint:fix` | Biome check with `--write` | | `pnpm knip` | Report unused files, exports, and dependencies | -Run the built CLI from a scratch directory. It scaffolds the new project into the folder it is -started from, so `node dist/cli.js` in this repo would write into the repo itself. +Run the built CLI from a scratch directory. It scaffolds the new project into the folder you start +it from, so `node dist/cli.js` in this repo would write into the repo itself. ## Demo recording @@ -96,40 +94,42 @@ few minutes. It cleans up after itself and overwrites `demo.svg`. Things worth knowing before touching it: -- The conversion is [svg-term-cli](https://github.com/marionebl/svg-term-cli), run through +- [svg-term-cli](https://github.com/marionebl/svg-term-cli) does the conversion, through `pnpm dlx`. It is not a dependency. The flags `--window --width 92 --height 23 --padding 10` - produce the committed geometry; change them and the readme's `` size needs to change too. -- `asciinema` cannot be scripted here. It ignores piped stdin, `script` refuses to start unless its - own stdin is a tty, and `node-pty` has no prebuilt binary for this machine. The script uses - Python's standard-library `pty` instead, which needs nothing installed. + produce the size of the committed image. If you change them, change the readme's `` size + too. +- There is no way to script `asciinema` here. It ignores piped stdin, `script` refuses to start + unless its own stdin is a tty, and `node-pty` has no prebuilt binary for this machine. The script + uses Python's standard-library `pty` instead, which needs nothing installed. - It waits for each prompt to appear in the output rather than sleeping a fixed time, so it does not break when a step gets slower. -- A `pnpm` shim on `PATH` makes `pnpm dlx dappbooster` run `dist/cli.js`. The recorded command line - is the real one while the code being demoed is the working tree. -- The recording is trimmed to 15 seconds so the loop stays short. Beyond that it is the package - install, which is a long stretch of near-static output and reads as a frozen image. -- Output within 150ms is merged into one frame. That cuts the file roughly five-fold, because the - spinner redraws every 80ms. It changes when bytes are flushed, never which bytes. +- A `pnpm` shim on `PATH` makes `pnpm dlx dappbooster` run `dist/cli.js`. The recording shows the + real command line, but runs the code in the working tree. +- The script trims the recording to 15 seconds so the loop stays short. After that point comes the + package install, a long stretch of near-static output that reads as a frozen image. +- The script merges output that arrives within 150 milliseconds into one frame. That cuts the file + about five-fold, because the spinner redraws every 80 milliseconds. It changes when bytes reach + the recording, never which bytes. ## Testing - **Framework:** Vitest + V8 coverage - **Run tests:** `pnpm test` / `pnpm test:coverage` -- **Structure:** `source/__tests__/` mirrors `source/` layout. Operations tests live in `source/__tests__/operations/` -- **What to test:** Non-interactive agentic flow (validation, JSON output), operations (correct shell commands), config, utils +- **Structure:** `source/__tests__/` mirrors the `source/` layout. Operations tests live in `source/__tests__/operations/` +- **What to test:** the non-interactive flow agents use (validation, JSON output), operations (correct shell commands), config, utils - **What not to test:** React/Ink components - **Mocking pattern:** Operations tests mock `exec`/`execFile` from `source/operations/exec.js`. `exec.test.ts` mocks `child_process.spawn` directly to test the helpers themselves. Non-interactive tests mock the entire operations layer -- **Coverage:** Focus on the agentic interface. Test files and `source/components/` are excluded from coverage +- **Coverage:** Focus on the interface agents use. Coverage leaves out the test files and `source/components/` ## Git hooks -`pnpm i` installs three hooks through husky: +`pnpm i` installs three hooks through Husky: - **commit-msg** runs commitlint against the message. - **pre-commit** runs lint-staged in two passes. `.lintstagedrc.format.mjs` lets Biome write the - staged files first; `.lintstagedrc.mjs` then runs the read-only gates (typecheck, tests, knip). - The split exists because a reformat landing mid-parse makes the gates fail at random. It finishes - by scanning the staged changes for secrets. + staged files first, then `.lintstagedrc.mjs` runs the read-only checks (typecheck, tests, knip). + There are two passes because a reformat that lands mid-parse makes the checks fail at random. The + hook ends by scanning the staged changes for secrets. - **pre-push** runs lint, typecheck and tests, then scans the outgoing commits for secrets. Secret scanning uses a pinned gitleaks. `scripts/install-gitleaks.sh` downloads that exact release @@ -143,26 +143,32 @@ the history by hand: ## Continuous integration -`.github/workflows/pr.yml` runs on every pull request: Biome, then typecheck and build and knip, -then the test suite on `.nvmrc` and again on the Node 24.15.0 floor, then commitlint over both the commit -range and the PR title, then gitleaks over the full history. A retitle only re-runs commitlint. +`.github/workflows/pr.yml` runs on every pull request, in this order: + +1. Biome +2. Typecheck, build and knip +3. The test suite on `.nvmrc`, and again on the Node 24.15.0 floor +4. commitlint over the commit range and the PR title +5. gitleaks over the full history + +Changing only the PR title re-runs just commitlint. `pr-assign.yml` assigns the author to their own pull request. `add-to-project.yml` adds new issues -and pull requests to the project board; it needs the `ADD_TO_PROJECT_PAT` secret, because the +and pull requests to the project board. It needs the `ADD_TO_PROJECT_PAT` secret, because the built-in token cannot write org projects. -## Commit Standards +## Commit standards Use [Conventional Commits](https://www.conventionalcommits.org/): `type(scope): subject`. - **Scope** is optional: `feat: add login` and `feat(auth): add login` are both valid - **Subject** uses imperative mood, lowercase after the colon, no trailing period -- **Body** (optional): separated by a blank line, explains *what* and *why* +- **Body** (optional): goes after a blank line and explains *what* and *why* -Allowed types, enforced by `commitlint.config.js`: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, +`commitlint.config.js` allows these types: `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `hotfix`, `perf`, `refactor`, `release`, `revert`, `style`, `test`, `wip`. -## PR Workflow +## PR workflow - Every PR must reference an issue (`Closes #`) @@ -170,27 +176,27 @@ Allowed types, enforced by `commitlint.config.js`: `build`, `chore`, `ci`, `docs - Mirror the issue's acceptance criteria in the PR - Self-review your diff before requesting peer review -- Keep PRs small and focused — one issue, one PR +- Keep PRs small and focused: one issue, one PR - PR titles use the same conventional commit format, and CI checks them - The `create-pr` skill at `.claude/skills/create-pr/` reads [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md) and fills every section -## Label Conventions +## Label conventions -GitHub form dropdowns (like the Priority field in issue templates) only work through the web UI. When issues are created via `gh` CLI or REST API, dropdown values become unstructured body text — not queryable, not consistent. **Labels are the API-reliable mechanism for structured metadata.** +GitHub form dropdowns (like the Priority field in issue templates) only work through the web UI. When you create an issue with the `gh` CLI or the REST API, dropdown values become plain body text that you cannot query and that differs from issue to issue. Labels are the reliable way to store structured metadata through the API. **Priority** (bugs, features, and epics): | Label | Description | |-------|-------------| | `priority: critical` | Blocking work, system down, or security issue | -| `priority: high` | Must be addressed in current sprint | -| `priority: medium` | Should be addressed soon | +| `priority: high` | Address in the current sprint | +| `priority: medium` | Address soon | | `priority: low` | Nice to have, can wait | -Labels are queryable: `gh issue list --label "priority: high"`. +You can query labels: `gh issue list --label "priority: high"`. -The `create-issue` skill at `.claude/skills/create-issue/` applies these labels automatically when creating issues via CLI. Bug, feature, and epic templates include a Priority dropdown for web UI users, but labels are the source of truth for programmatic workflows. +The `create-issue` skill at `.claude/skills/create-issue/` applies these labels when it creates issues with the CLI. Bug, feature, and epic templates include a Priority dropdown for web UI users, but labels are the source of truth for programmatic workflows. ## Guardrails @@ -199,20 +205,20 @@ The `create-issue` skill at `.claude/skills/create-issue/` applies these labels - Do not modify CI/CD pipelines without team review - Do not skip tests or linting to make a build pass - Pin third-party GitHub Actions to a commit SHA with the version in a trailing comment -- Raising `engines.node` breaks installs for everyone below the new floor; treat it as a release +- Raising `engines.node` breaks installs for everyone below the new floor. Treat it as a release decision, not a cleanup -- `ink` and `react` stay on 5 and 18: `ink-divider` still depends on `ink` 5, so moving to `ink` 7 +- `ink` and `react` stay on 5 and 18. `ink-divider` still depends on `ink` 5, so moving to `ink` 7 would load two copies of the renderer -- When in doubt, ask — don't assume +- When in doubt, ask. Don't assume -## Change Strategy +## Change strategy - Prefer small, focused diffs over broad refactors - Preserve existing UX unless the task explicitly changes it - Avoid introducing new patterns when a project pattern already exists - Update docs only when behavior or workflow changes -## Validation Checklist +## Validation checklist Run all five before declaring work done. CI runs the same set. @@ -224,6 +230,6 @@ Run all five before declaring work done. CI runs the same set. ## Release -GitHub Actions workflow (`.github/workflows/release.yml`) triggers on GitHub release events. Pre-releases do a dry-run; full releases publish to npm. +The GitHub Actions workflow `.github/workflows/release.yml` runs on GitHub release events. A pre-release does a dry run; a full release publishes to npm. -It publishes with `npm publish`, not `pnpm publish`, because npm is what supports OIDC trusted publishers. That is also why the job asks for `id-token: write` instead of carrying an npm token. +It publishes with `npm publish`, not `pnpm publish`, because npm supports OIDC trusted publishers. That is also why the job asks for `id-token: write` instead of carrying an npm token. diff --git a/architecture.md b/architecture.md index 15a4f32..0db84c0 100644 --- a/architecture.md +++ b/architecture.md @@ -1,8 +1,8 @@ -# Architecture Overview +# Architecture overview Architecture guide for the dAppBooster installer. -## Tech Stack +## Tech stack | Category | Technology | Notes | |----------|-----------|-------| @@ -12,270 +12,217 @@ Architecture guide for the dAppBooster installer. | Styling | Ink primitives | ``, ``, 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`; 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 the scaffolded stacks need; `.nvmrc` is what CI uses. A stack can still ask for more through `minNodeVersion` | +| 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` | -## Project Structure +## Project structure ``` source/ - cli.tsx Entry point: meow arg parsing, stack resolution, mode routing - app.tsx Interactive TUI: step-based state machine, threads `stack` through every step + 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 (optionally filtered by stack) - stacks/ - evm.ts The EVM StackConfig and its features - canton.ts The Canton StackConfig (no features) - index.ts stackDefinitions, stackNames, and the config/feature accessors + 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 stack.minNodeVersion, clone at the newest tag or at stack.ref, rm .git, git init - createEnvFile.ts Copy each stack's envFiles - installPackages.ts Stack-aware: uses stack.packageManager (pnpm) - cleanupFiles.ts Applies stack.prepare, removes deselected features, patches package.json — all before the install - createInitialCommit.ts Commits the finished scaffold (stacks that ask for it) + 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 - StackSelection.tsx Pick a stack (skipped when preselectedStack is passed) - CloneRepo/CloneRepo.tsx Clone progress display (receives stack) - InstallationMode.tsx Mode selection (skipped for a stack with no features) - OptionalPackages.tsx Feature multiselect (skipped for a stack with no features) + 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 files, package install and baseline commit + Install/Install.tsx Env file and package install StepProgress.tsx Shared runner for the operation steps: progress, errors, guard - PostInstall.tsx Renders the stack's postInstallComponent, or its postInstall lines - EvmPostInstall.tsx The EVM closing screen, including the subgraph warning - CantonPostInstall.tsx The Canton closing screen, including the dev-stack steps + PostInstall.tsx Closing screen, including the subgraph warning Ask.tsx Text input with validation Divider.tsx Section divider - MainTitle.tsx Gradient title banner, with a badge for the chosen stack + MainTitle.tsx Gradient title banner Multiselect/ Checkbox multiselect component types/ types.ts Shared TypeScript types utils/ - utils.ts Stack-aware helpers, feature-dependency resolution, validation, path helpers - __tests__/ Mirrors source/ layout + utils.ts Feature and mode helpers, validation, path helpers + __tests__/ Mirrors the source/ layout ``` -## Key Abstractions +## Key abstractions -### Stack (`source/stacks/`) +### Config (`source/config.ts`) -Each stack owns a module — `source/stacks/evm.ts` and `source/stacks/canton.ts` — exporting one `StackConfig`. `source/stacks/index.ts` holds the `stackDefinitions` record, `stackNames`, and the accessors below. The types live in `source/types/types.ts`. Nothing outside `source/stacks/` and `cli.tsx` tests a stack by name. +Everything the installer knows about the dAppBooster template: -```ts -type Stack = 'evm' | 'canton' - -type StackConfig = { - label: string - description: string - repoUrl: string - ref?: string // tag or branch to clone; left out, the newest tag wins - packageManager: 'pnpm' - minNodeVersion?: string // checked before the clone (Canton: 24.15.0) - prepare?: PrepareStep // paths and package.json keys every scaffold drops - postInstall?: string[] // stack-level post-install guidance, shown for every scaffold - postInstallComponent?: () => Promise<{ default: FC }> // richer wizard closing screen (EVM), imported on demand so --info and the non-interactive path never load Ink; otherwise the lines above are printed - staging?: { label: string; paths: string[] } // where the template keeps replacement files; removed once cleanup is done (EVM's .install-files) - initialCommit?: boolean // commit the finished scaffold as the project's baseline (Canton) - envFiles: Array<{ from: string; to: string }> - features: Record -} - -type PrepareStep = { - label: string - paths?: string[] // deleted before the install - scripts?: string[] // package.json scripts deleted before the install - devDependencies?: string[] // package.json devDependencies deleted before the install -} -``` +- `featureDefinitions`: one entry per optional feature. `featureEntries`, `featureNames` and `defaultFeatureNames` come from it. +- `isFeatureNameValid(name)`: a type guard, so validated CLI input narrows from `string` to `FeatureName`. +- `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-files` directory, which holds the replacement files a deselected feature restores. Cleanup removes it last. +- `getRepo()`: the repository URL and an optional `ref`. Without a `ref`, the installer clones the newest tag. The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override both, for forks and pre-release testing. +- `packageManager`: `'pnpm'`, the package manager `cloneRepo` checks for and `installPackages` runs. -`prepare` is what a scaffold drops whatever the user picked. EVM uses it for the template's own repo files (`.github`, `.claude`, the agent docs). Canton uses it for the bulk of its scaffolding: the three in-tree libraries, `kit/`, the agent docs and `pnpm-lock.yaml`, plus the `release` and `release:dry` scripts and the `typedoc`, `postcss` and `@mermaid-js/mermaid-cli` dev-dependencies that only served them. The other five scripts it drops are not declared: `cleanupFiles` records which deleted paths were directories, so any script whose command names `kit/` goes with the folder. Both halves must go together, because knip counts a binary named in a script as used. It runs inside `cleanupFiles`, before the single package install, so the package manager never resolves a dependency the manifest is about to lose. +`FeatureName` is the union of every feature name, declared in `source/types/types.ts`. `featureDefinitions` has the type `Record`, 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 stack-aware via `getInstallationModes(stack)` — EVM offers `full` / `custom`, Canton offers none. A stack with no features returns an empty list: the wizard skips both questions and the CLI rejects `--mode` and `--features`. `default` mode needs at least one `default: false` feature, or it would equal `full`; no stack has one today. +Installation modes are `full` (keep every feature) and `custom` (pick them). -`getStackConfig(stack)` reads the base config and overlays the env-var overrides `DAPPBOOSTER__REPO_URL` and `DAPPBOOSTER__REF` before returning — that's the single hook for retargeting either stack at a fork or pre-release branch without editing code. +### Feature definitions -`getFeatureNames(stack)`, `getFeatureEntries(stack)` and `isFeatureNameValid(stack, name)` are the per-stack feature accessors. There is no global `featureDefinitions` export — that would imply a single stack. - -`FeatureName` is the union of every feature name the stacks define, declared in `source/types/types.ts`. Only EVM has features, and its `satisfies StackConfig & { features: Record }` clause requires its feature map to hold exactly those keys, 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. `isFeatureNameValid` is a type guard, so validated CLI input narrows from `string` to `FeatureName`. - -### Feature Definitions - -Stored inside each stack's `features` map. Shape: +Shape: ```ts 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 - postInstall?: string[] // post-install instructions for non-interactive JSON output + 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 - requires?: FeatureName[] // features this one depends on (one-directional, transitive) } ``` -When adding a new feature, add its name to the `FeatureName` union in `source/types/types.ts` and an entry to the stack's `features` map. Programmatic consumers pick it up automatically. Feature cleanup is data-driven from `paths` and `scripts`, and removal of its `packages` is data-driven too (see the Operations Layer below), so a new feature usually needs no code. The one exception is EVM's `demo` and `subgraph`, which restore replacement source files from the template's staging directory. The CLI `--help` text in `cli.tsx` maintains its own copy either way. +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. -`packages` are always removed by the package manager (`pnpm remove`), which updates package.json and the lockfile together. Nothing hand-edits dependencies. +The package manager always removes `packages` (`pnpm remove`), which updates package.json and the lockfile together. Nothing edits dependencies by hand. -**Feature dependencies (`requires`)** are resolved by pure helpers in `utils.ts`. `resolveSelectedFeatures(stack, selected)` expands a selection to include every transitive requirement; `resolveModeFeatures(stack, mode, customSelection)` maps a mode to its kept-feature list (full → all, default → the `default: true` set, custom → the resolved selection), each with its `requires` resolved. 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. `applyFeatureToggle(stack, selection, toggled, action)` keeps the interactive multiselect consistent: selecting a feature pulls its requirements in, deselecting one cascades its dependents out. No feature declares `requires` today (the machinery remains for future use); `--info` surfaces each feature's `requires` so agents can resolve dependencies themselves. +`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. -### Operations Layer (`source/operations/`) +### Operations layer (`source/operations/`) -Plain async functions, no UI dependencies. Each operation that varies per stack takes `stack: Stack` as its first argument. Multi-step operations accept an optional `onProgress` callback for the TUI; the non-interactive path omits it. +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(stack, projectName, onProgress?)` | Fails first when the running Node is below `stack.minNodeVersion`, before anything touches the disk. Then clones. Without `stack.ref` (both stacks today): shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). With `stack.ref` — which today only `DAPPBOOSTER__REF` sets — that tag or branch wins over the latest tag: `git fetch --depth 1 origin ` then `git checkout FETCH_HEAD`, no shell. Removes `.git` and reinitializes with `git init`. Uses `execFile` everywhere except the latest-tag shell substitution. | -| `createEnvFile(stack, projectFolder)` | Copies every entry from `stack.envFiles`. | -| `installPackages(stack, projectFolder, mode, features, onProgress?)` | Uses `stack.packageManager`. Nothing to remove (full mode, or a selection that drops no packages): ` install`. Otherwise ` remove`, which prunes the manifest and the lockfile together, then ` run postinstall` **only if the template defines that script** — the Canton template does not. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | -| `cleanupFiles(stack, projectFolder, mode, features, onProgress?)` | Config-driven for both stacks, and runs **before** the install. First the stack's `prepare` paths, dropped from every scaffold. Then, in `default` and `custom` modes, it loops the stack's features and for each one the user left out removes its `paths` and collects its `scripts`. Removed **directories** also strip scripts by command target: any script whose command invokes a removed directory is dropped, so dropping `vocs` strips a script that runs `docs/`. Removed *files* never strip scripts, so a script that merely mentions `CLAUDE.md` survives. package.json is then patched in a single pass — the `prepare` scripts and dev-dependencies, plus the deselected features' scripts — read once and written only when a value changed. Feature dependencies are left to `installPackages`; `prepare` dev-dependencies are deleted here, because the package manager must never see them. A stack with a `staging` group additionally restores the demo-free home page from the staged copies when `demo` or `subgraph` is dropped, and the staging directory (EVM's `.install-files`) goes last, once the restores no longer need it. | +| `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 `, 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. | #### Interrupt safety (`installGuard`) -`source/operations/installGuard.ts` makes a Ctrl+C or a failure mid-scaffold leave no partial directory behind. `beginInstall(projectFolder)` is called the instant disk work starts (before `cloneRepo`) and registers `SIGINT`/`SIGTERM` handlers; `completeInstall()` is called once cleanup finishes; `abortInstall()` is called when an operation throws — it removes the partial directory and sets `process.exitCode = 1`, so a failed interactive run reports failure to the shell instead of exiting 0. The three interactive operation steps (`CloneRepo`, `Install`, `FileCleanup`) all call it from their `catch`. On an interrupt while a scaffold is in progress, the handler removes the project directory; after `completeInstall` it is a no-op, so a finished project (or a Ctrl+C on the post-install screen) is never deleted. It only ever removes a directory created this run — both entry paths reject a pre-existing directory up front — so user data is never touched. Both paths wire it in: the non-interactive runner brackets its operation block, and interactively `CloneRepo` calls `beginInstall` while `Install`, the last operation step, calls `completeInstall`. +`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, before `cloneRepo`. It registers the `SIGINT` and `SIGTERM` handlers. +- `completeInstall()` runs once the install finishes. +- `abortInstall()` runs when an operation throws. It removes the partial directory and sets `process.exitCode = 1`, so a failed interactive run reports failure to the shell instead of exiting 0. -### Shell Execution (`source/operations/exec.ts`) +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`. + +### Shell execution (`source/operations/exec.ts`) Two helpers with different security profiles: -- **`execFile(file, args, options)`** — wraps `child_process.spawn` without 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)`** — wraps `child_process.spawn` to run `/bin/sh -c ` (spawns a shell). Only for commands that require shell features like `$(...)` substitution. Never interpolate user input into the command string. +- **`execFile(file, args, options)`** wraps `child_process.spawn` without 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 as `projectName`, appears in the command. +- **`exec(command, options)`** wraps `child_process.spawn` to run `/bin/sh -c `, 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 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. +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. ### Security -- User input (`projectName`) is validated against `/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/` before any use. The leading 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 stack-config values. -- `exec` (shell) is reserved for the tag-latest checkout (`git checkout $(git describe …)`); it never receives user input in the command string. -- Stack `repoUrl` and `ref` may come from the environment (`DAPPBOOSTER__REPO_URL`, `DAPPBOOSTER__REF`) but are passed to git via `execFile`, not interpolated into shell strings. -- Child process stdout is ignored and stderr is piped (captured for error diagnostics only), guaranteeing clean JSON on the parent's stdout. +- 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 …)`) uses `exec` (shell), and its command string never includes user input. +- The repository URL and ref may come from the environment (`DAPPBOOSTER_REPO_URL`, `DAPPBOOSTER_REF`). `execFile` passes 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. -## Data Flow +## Data flow ### Non-interactive (agent) ``` CLI flags (string) → meow parses to typed flags - → resolveStackFlag merges --canton / --evm / --stack and rejects conflicts - → validate() converts to { stack, name, mode, features: FeatureName[] } - → operations receive typed args (stack first) + → validate() converts to { name, mode, features: FeatureName[] } + → operations receive typed args → JSON output to stdout ``` **Routing:** `source/cli.tsx` ``` -conflicting stack flags → JSON error → exit 1 ---info → source/info.ts → print JSON (optionally filtered by stack) → exit 0 +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 (preselectedStack passed if resolved) → TUI +default → dynamic import ink + App → TUI ``` **Non-interactive validation order:** -1. `--stack` (if explicit) is a valid stack name (else error). When unset, defaults to `evm`. -2. `--name` required -3. `--name` matches `/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/` — a leading dash is rejected because git would read the name as an option -4. Stack with no modes (`getInstallationModes(stack)` is empty, i.e. Canton): `--mode` and `--features` are rejected by name, then skip to step 10 with `mode: 'full'` and no features -5. `--mode` required -6. `--mode` is `full`, `default`, or `custom` -7. `--mode` is one of the modes the stack offers — the same list `--info` reports (so `default` is rejected for `evm`) -8. Full / default mode: skip to step 10 (features come from the mode, `--features` ignored) -9. Custom mode: `--features` required, parses to a non-empty list (rejects trailing commas, whitespace-only entries), and every name is valid **for the selected stack** -10. Project directory does not already exist - -The kept-feature list is derived from the mode via `resolveModeFeatures` (see -[feature definitions](#feature-definitions)); for custom mode this expands the selection -with `resolveSelectedFeatures` so any feature dependencies are pulled in before the operations run -and before the result is reported. +1. `--name` must be present +2. `--name` matches `/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/`. A leading dash fails, because git would read the name as an option +3. `--mode` must be present +4. `--mode` is `full` or `custom` +5. Full mode: skip to step 7 (it keeps every feature and ignores `--features`) +6. Custom mode: `--features` must be present, must parse to a non-empty list (it rejects trailing commas and whitespace-only entries), and every name must be valid +7. The project directory must not already exist **Non-interactive execution order:** -`cloneRepo` → `cleanupFiles` → `createEnvFile` → `installPackages` → `createInitialCommit` (stacks whose config sets `initialCommit`) → success JSON +`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 the lockfile it writes 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. It is also what makes Canton's `prepare` step work: the library folders and the old lockfile are gone before the resolve, so the same manifest resolves the three `@bootnodedev/*` libraries from npm instead of linking the local folders. The baseline commit runs last, so it captures that lockfile. +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 terminates when piped. +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:** ```json { "success": true, - "stack": "evm|canton", "projectName": "...", - "mode": "full|default|custom", + "mode": "full|custom", "features": ["..."], "path": "/absolute/path", "postInstall": ["..."] } ``` -For full mode, `features` lists all of the stack's feature names; for custom mode, the selected ones plus any dependencies they pulled in. A stack with no features always reports `"mode": "full"` and an empty `features` list. +In full mode, `features` lists every feature name. In custom mode it lists the selected ones, in config order. ### Interactive (human) ``` User input via Ink components - → useState in App.tsx (stack, projectName, setupType, selectedFeatures) + → useState in App.tsx (projectName, setupType, selectedFeatures) → passed as props to step components → components convert MultiSelectItem[] → FeatureName[] - → operations receive typed args (stack first) + → operations receive typed args → Ink renders progress/status ``` -All questions come **before** any disk work, mirroring the non-interactive path — so abandoning the wizard while answering leaves nothing behind. +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 → [StackSelection] → [InstallationMode → OptionalPackages (custom only) → Confirmation] +Questions (no disk): ProjectName → InstallationMode → OptionalPackages (custom only) → Confirmation Operations (disk): CloneRepo → FileCleanup → Install → PostInstall ``` -The last bracketed group is dropped for a stack whose `getInstallationModes` list is empty: with nothing to choose there is nothing to review, so a Canton run asks for the project name and then scaffolds. - -Each operation step renders through the shared `StepProgress` component, which owns the step list, the running/done/error display and the failure path (`abortInstall`), so every step reports a failure the same way. `Install` covers env files, the package install and the baseline commit, and calls `completeInstall` when the scaffold is finished. - -`Confirmation` shows the plan as one line per setting (`describeInstallPlan`), each value highlighted, and is the last side-effect-free step. **Yes** starts the operations; **No** loops back to the first question (state is reset and the question steps are re-keyed so they re-mount fresh). When `cli.tsx` resolves a stack flag, it passes `preselectedStack` to ``, which skips the `StackSelection` step. - -Nothing is lost by dropping `Confirmation` for a featureless stack: the project name is validated as it is typed, and an interrupt or a failure during the operations removes the partial directory (see installGuard below). - -Once operations begin, `CloneRepo` calls `beginInstall` (see [interrupt safety](#interrupt-safety-installguard)) and `FileCleanup` calls `completeInstall` on success, so a Ctrl+C mid-scaffold removes the partial directory while a finished project is left intact. - -Components are presentation-only — they call operations via `useEffect` and render status. Components receive `MultiSelectItem[]` for feature selection (TUI concern), then derive the kept-feature `FeatureName[]` via `resolveModeFeatures(stack, mode, selected)` before calling operations — so `full`/`default` resolve correctly even though the multiselect is skipped. The `OptionalPackages` multiselect pre-checks `default: true` features and enforces feature dependencies live via `applyFeatureToggle`. `PostInstall` names no stack: it renders the stack's own `postInstallComponent` when there is one (EVM's, which adds the subgraph warning when applicable), and otherwise prints a `cd` line and the stack's `postInstall` lines. - -## Extending the Installer +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. -### How to Add a New Stack +`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. -1. **`source/types/types.ts`** — add a `Stack` union member. -2. **`source/stacks/.ts`** — export a `StackConfig`: `label`, `description`, `repoUrl`, optional `ref`, `packageManager`, `envFiles`, `features`. Add `minNodeVersion` when the scaffold needs a newer Node than the installer, `prepare` for what every scaffold drops, `staging` for replacement files, and `initialCommit` for a baseline commit. `cleanupFiles` and `cloneRepo` read all of it, so they need no new branch. -3. **`source/stacks/index.ts`** — add the module to the `stackDefinitions` record. -4. **Post-install** — a stack with a short message needs nothing: `PostInstall.tsx` prints its `postInstall` lines. For a richer screen, add a component and set `postInstallComponent` to a function that imports it (`() => import('../components/steps/MyPostInstall.js')`), so the stack config stays free of terminal UI. -5. **`source/cli.tsx`** — add a shortcut flag (e.g. `--myStack`) and extend `resolveStackFlag`; update `--help` text. -6. **Tests** — add per-stack assertions to `nonInteractive.test.ts`, `info.test.ts`, `cloneRepo.test.ts`, `installPackages.test.ts`, `cleanupFiles.test.ts`, `createEnvFile.test.ts`. -7. **Verify** — `pnpm build && pnpm lint && pnpm test`. Smoke-test with `DAPPBOOSTER__REPO_URL=file:///path/to/local/clone`. +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. -### How to Add a New Feature to an Existing Stack +## Extending the installer -1. **`source/types/types.ts`** — add the name to the `FeatureName` union. -2. **`source/stacks/.ts`** — add an entry to the stack's `features` map (leave one out and the file will not compile). The `default` flag governs both the custom-mode pre-check and `default`-mode membership: `default: true` for "kept by the recommended install", `default: false` for "removed by default / opt-in". List the feature's `paths`, `scripts` and `packages`; cleanup and the install read all three, so no new code is needed. If it depends on another feature, add `requires` — resolution is automatic in both paths. -3. **`source/operations/cleanupFiles.ts`** — only needed when the feature has to put a replacement file back, the way EVM's `demo` and `subgraph` copy from `.install-files`. -4. **Post-install** — extend the stack's `postInstall` lines or its `postInstallComponent` if needed. -5. **`source/cli.tsx`** — update the `--help` text. -6. **Tests** — add assertions in the relevant test files. nonInteractive, info, installPackages, and utils tests pick up new features automatically through `stackDefinitions`. -7. **Verify** — `pnpm build && pnpm lint && pnpm test`. +### How to add a new feature -> Adding the first feature to a stack that had none also turns its wizard questions and its `--mode` / `--features` flags back on, because `getInstallationModes` stops returning an empty list. +1. **`source/types/types.ts`**: add the name to the `FeatureName` union. +2. **`source/config.ts`**: add an entry to `featureDefinitions`. Leave one out and the file does not compile. `default: true` pre-checks the feature in the custom-mode multiselect. List the feature's `paths`, `scripts` and `packages`. Cleanup and the install read all three, so you need no new code. +3. **`source/operations/cleanupFiles.ts`**: only needed when the feature has to put a replacement file back, the way `demo` and `subgraph` copy from `.install-files`. +4. **Post-install**: add `postInstall` lines for the JSON output, and extend `PostInstall.tsx` if the wizard needs to say more. +5. **`source/cli.tsx`**: update the `--help` text. +6. **Tests**: add assertions in the relevant test files. The nonInteractive, info, installPackages, and utils tests pick up new features from `featureDefinitions`. +7. **Verify**: `pnpm build && pnpm lint && pnpm test`. -### How to Add a New Operation +### How to add a new operation -1. Create `source/operations/newOperation.ts` — export an async function. Use `execFile` for commands with user input, `exec` only when shell features are needed. If behavior depends on the stack, take `stack: Stack` as the first argument. -2. Export from `source/operations/index.ts`. -3. Call from `source/nonInteractive.ts` (in the execution sequence) and from the relevant TUI component. -4. Add tests in `source/__tests__/operations/newOperation.test.ts` — mock `exec`/`execFile` to verify correct commands. +1. Create `source/operations/newOperation.ts` and export an async function from it. Use `execFile` for commands with user input, and `exec` only when you need shell features. +2. Export it from `source/operations/index.ts`. +3. Call it from `source/nonInteractive.ts`, in the execution sequence, and from the relevant TUI component. +4. Add tests in `source/__tests__/operations/newOperation.test.ts`. Mock `exec` and `execFile` to check the commands. diff --git a/demo.svg b/demo.svg index a81cbed..8f42529 100644 --- a/demo.svg +++ b/demo.svg @@ -1 +1 @@ -$pnpmdlxdappbooster╔╦╗╔═╗╔═╗╔═╗╔╗╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗║║╠═╣╠═╝╠═╝╠╩╗║║║║╚═╗║║╣╠╦╝═╩╝╩╩╩╩╚═╝╚═╝╚═╝╚═╝╩╚═╝╩╚═Letters,numbers,underscoresandnon-initialdashesareallowed.Projectname:myNewAppSelectstackStack:EVM────────────────────────────────────────────────────────────────Installationsetup──────────────────────────────────────────────────────────Installationtype:FullProject:myNewAppMode:FullReview──────────────────────────────────────────────────────────────────────✔Scaffolding…Gittasks─────────────────────────────────────────────────────────────────���─⠙CloningEVMinmyNewApp⠸CloningEVMinmyNewApp⠴CloningEVMinmyNewApp✔CloningEVMinmyNewApp⠧Fetchingtags⠏Fetchingtags⠙Fetchingtags$$pn$pnpm$pnpmd$pnpmdlx$pnpmdlxd$pnpmdlxdap$pnpmdlxdappb$pnpmdlxdappboo$pnpmdlxdappboostProjectname:Projectname:myProjectname:myNeProjectname:myNewAProjectname:myNewApp────────────────────────────────Selectstack────────────────────────────────Whichstackdoyouwanttoscaffold?❯EVM—dAppBoosterforEVMchains(Ethereum,Polygon,Base,…)Canton—dAppBoosterforCanton(Damlledger,off-chainservices)═╩╝╩╩╩╩╚═╝╚═╝╚═╝╚═╝╩╚═╝╩╚═EVM───────────────────────────────────────��─────────────────────────────────────────────────────Installationsetup─────────────────────────────Chooseinstallationtype❯FullCustom───────────────────────────────────Review───────────────────────────────────Proceedwiththesesettings?❯Yes.No,startover.⠧CloningEVMinmyNewApp⠏CloningEVMinmyNewApp⠸Fetchingtags⠴Fetchingtags \ No newline at end of file +$pnpmdlxdappbooster╔╦╗╔═╗╔═╗╔═╗╔╗╔═╗╔═╗╔═╗╔╦╗╔═╗╦═╗║║╠═╣╠═╝╠═╝╠╩╗║║║║╚═╗║║╣╠╦╝═╩╝╩╩╩╩╚═╝╚═╝╚═╝╚═╝╩╚═╝╩╚═Letters,numbers,underscoresandnon-initialdashesareallowed.Projectname:myNewAppInstallationsetup──────────────────────────────────────────────────────────Installationtype:FullProject:myNewAppMode:FullReview──────────────────────────────────────────────────────────────────────✔Scaffolding…Gittasks───────────────────────────────────────────────────────────────────⠙CloningdAppBoosterinmyNewApp⠸CloningdAppBoosterinmyNewApp✔CloningdAppBoosterinmyNewApp⠴Fetchingtags⠧Fetchingtags⠏Fetchingtags⠙Fetchingtags⠸Fetchingtags$$pn$pnpm$pnpmd$pnpmdlx$pnpmdlxd$pnpmdlxdap$pnpmdlxdappb$pnpmdlxdappboo$pnpmdlxdappboostProjectname:Projectname:myProjectname:myNeProjectname:myNewAProjectname:myNewApp─────────────────────────────Installationsetup─────────────────────────────Chooseinstallationtype❯FullCustom────────────────��──────────────────Review───────────────────────────────────Proceedwiththesesettings?❯Yes.No,startover.──────────────────────────────────Gittasks─────────────────────────────────⠴CloningdAppBoosterinmyNewApp⠧CloningdAppBoosterinmyNewApp⠏CloningdAppBoosterinmyNewApp \ No newline at end of file diff --git a/readme.md b/readme.md index 8406284..d70ff35 100644 --- a/readme.md +++ b/readme.md @@ -1,12 +1,14 @@ # dAppBooster installer +Scaffolds a new project from [dAppBooster](https://dappbooster.dev/), our blockchain boilerplate for EVM projects. + +Looking for Canton? It has its own installer: [`create-canton-dappbooster`](https://github.com/BootNodeDev/canton-dappbooster/tree/main/canton-create). + ## Requirements - Node >= 24.15.0 - pnpm -Requirements for each dAppBooster stack might differ, check the [EVM](https://github.com/bootnodedev/dappbooster/) and [Canton](https://github.com/BootNodeDev/canton-dappbooster) repos for more info. - ## Interactive mode Terminal recording of the dAppBooster wizard. @@ -15,44 +17,26 @@ Requirements for each dAppBooster stack might differ, check the [EVM](https://gi pnpm dlx dappbooster ``` -The wizard will guide you through the different choices available: project name, stack, features, etc. +The wizard guides you through the choices: project name, installation mode and features. -Alternatively you can start with a pre-selected stack: - -- **EVM:** our [blockchain boilerplate](https://dappbooster.dev/) built to quickly get you started with your next EVM project. -- **Canton:** our [local development stack](https://www.dappbooster.cc/) for the Canton network. - -```shell -pnpm dlx dappbooster --evm # EVM stack -pnpm dlx dappbooster --canton # Canton stack -``` - -## Non-interactive mode (Agents & CI) +## Non-interactive mode (agents & CI) | Flag | Purpose | |---|---| -| `--canton` / `--evm` | Pick the stack (mutually exclusive shortcuts) | -| `--stack ` | Pick the stack by name (useful when scripting) | | `--name ` | Project directory name (`/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/`, so no leading dash) | -| `--mode ` | `full` installs every feature; `custom` needs `--features`. Only for a stack whose `modes` list is not empty | +| `--mode ` | `full` installs every feature; `custom` needs `--features` | | `--features ` | Comma-separated feature keys (custom mode only) | -| `--info` | Print stacks and features as JSON and exit. Filter with `--stack`, `--canton` or `--evm` | -| `--ni`, `--non-interactive` | Force non-interactive mode. Auto-enabled when stdout is not a TTY | +| `--info` | Print the features as JSON and exit | +| `--ni`, `--non-interactive` | Force non-interactive mode. It turns on by itself when stdout is not a TTY | | `--help` | Show the usage text | | `--version` | Show the installer version | -Example: discover stacks and features first, then install. +Example: discover the features first, then install. ```shell -pnpm dlx dappbooster --info # all stacks + features as JSON -pnpm dlx dappbooster --info --stack canton # filter to one stack (or --info --canton) -``` - -### EVM stack - -```shell -pnpm dlx dappbooster --evm --ni --name my_dapp --mode full -pnpm dlx dappbooster --evm --ni --name my_dapp --mode custom --features demo,subgraph +pnpm dlx dappbooster --info +pnpm dlx dappbooster --ni --name my_dapp --mode full +pnpm dlx dappbooster --ni --name my_dapp --mode custom --features demo,subgraph ``` **Feature options:** @@ -65,12 +49,6 @@ pnpm dlx dappbooster --evm --ni --name my_dapp --mode custom --features demo,sub | Vocs | `vocs` | ✓ | Vocs documentation site | | Husky | `husky` | ✓ | Git hooks with Husky, lint-staged, and commitlint | -### Canton stack - -```shell -pnpm dlx dappbooster --canton --ni --name my_canton_dapp -``` - ## Installer development ```shell @@ -81,4 +59,4 @@ corepack enable pnpm i pnpm build node dist/cli.js -``` \ No newline at end of file +``` diff --git a/scripts/record-demo.py b/scripts/record-demo.py index 49b3282..cefcc78 100755 --- a/scripts/record-demo.py +++ b/scripts/record-demo.py @@ -129,10 +129,6 @@ def type_out(text: str, per_char: float = 0.085) -> None: pump(0.6) send("\r") - expect(r"Which stack", 15, "stack prompt") - pump(1.3) - send("\r") # EVM is first - expect(r"Choose installation type", 15, "mode prompt") pump(1.3) send("\r") # Full is first diff --git a/source/__tests__/config.test.ts b/source/__tests__/config.test.ts index 50d496f..ccb70c9 100644 --- a/source/__tests__/config.test.ts +++ b/source/__tests__/config.test.ts @@ -1,59 +1,18 @@ import { describe, expect, it } from 'vitest' -import { - getDefaultFeatureNames, - getFeatureNames, - getInstallationModes, - stackDefinitions, -} from '../stacks/index.js' +import { defaultFeatureNames, featureDefinitions, featureNames } from '../config.js' -describe('getDefaultFeatureNames', () => { +describe('defaultFeatureNames', () => { it('returns only features whose default flag is true, in config order', () => { - for (const stack of ['evm', 'canton'] as const) { - const expected = Object.entries(stackDefinitions[stack].features) - .filter(([, def]) => def.default) - .map(([name]) => name) - expect(getDefaultFeatureNames(stack)).toEqual(expected) - } + const expected = Object.entries(featureDefinitions) + .filter(([, def]) => def.default) + .map(([name]) => name) + expect(defaultFeatureNames).toEqual(expected) }) it('is a subset of all feature names', () => { - const all = new Set(getFeatureNames('evm')) - for (const name of getDefaultFeatureNames('evm')) { + const all = new Set(featureNames) + for (const name of defaultFeatureNames) { expect(all.has(name)).toBe(true) } }) }) - -describe('getInstallationModes', () => { - it('offers full/custom for evm, where every feature is on by default', () => { - expect(getInstallationModes('evm')).toEqual(['full', 'custom']) - }) - - it('offers none for canton, which has no features to choose', () => { - expect(getInstallationModes('canton')).toEqual([]) - }) -}) - -describe('canton stack config', () => { - it('clones canton-dappbooster at its latest tag with pnpm', () => { - expect(stackDefinitions.canton.repoUrl).toBe( - 'https://github.com/BootNodeDev/canton-dappbooster.git', - ) - expect(stackDefinitions.canton).not.toHaveProperty('ref') - expect(stackDefinitions.canton.packageManager).toBe('pnpm') - }) - - it('copies a single root env file', () => { - expect(stackDefinitions.canton.envFiles).toEqual([{ from: '.env.example', to: '.env' }]) - }) - - it('declares the Node floor the scaffolded project needs', () => { - expect(stackDefinitions.canton.minNodeVersion).toBe('24.15.0') - }) - - it('deletes the three library folders, so pnpm resolves them from npm', () => { - for (const library of ['canton-connect', 'canton-dappbooster', 'canton-theme']) { - expect(stackDefinitions.canton.prepare.paths).toContain(library) - } - }) -}) diff --git a/source/__tests__/info.test.ts b/source/__tests__/info.test.ts index c10cff8..95a4371 100644 --- a/source/__tests__/info.test.ts +++ b/source/__tests__/info.test.ts @@ -1,86 +1,41 @@ import { describe, expect, it } from 'vitest' +import { featureEntries, featureNames } from '../config.js' import { getInfoOutput } from '../info.js' -import { - getFeatureEntries, - getInstallationModes, - stackDefinitions, - stackNames, -} from '../stacks/index.js' -describe('getInfoOutput — no filter', () => { +describe('getInfoOutput', () => { it('returns valid JSON', () => { expect(() => JSON.parse(getInfoOutput())).not.toThrow() }) - it('has stacks and modes top-level keys', () => { + it('has features and modes top-level keys', () => { const output = JSON.parse(getInfoOutput()) - expect(output).toHaveProperty('stacks') - expect(output).toHaveProperty('modes') + expect(Object.keys(output)).toEqual(['features', 'modes']) }) - it('includes every defined stack', () => { + it('lists every feature', () => { const output = JSON.parse(getInfoOutput()) - expect(Object.keys(output.stacks)).toEqual(stackNames) - }) - - it('each stack lists its features', () => { - const output = JSON.parse(getInfoOutput()) - - for (const stack of stackNames) { - const expected = Object.keys(stackDefinitions[stack].features) - expect(Object.keys(output.stacks[stack].features)).toEqual(expected) - } - }) - - it('each stack reports its package manager and label', () => { - const output = JSON.parse(getInfoOutput()) - - for (const stack of stackNames) { - expect(output.stacks[stack].label).toBe(stackDefinitions[stack].label) - expect(output.stacks[stack].packageManager).toBe(stackDefinitions[stack].packageManager) - } + expect(Object.keys(output.features)).toEqual(featureNames) }) it('each feature has description and default', () => { const output = JSON.parse(getInfoOutput()) - for (const stack of stackNames) { - for (const name of Object.keys(stackDefinitions[stack].features)) { - const feature = output.stacks[stack].features[name] - expect(feature).toHaveProperty('description') - expect(feature).toHaveProperty('default') - expect(typeof feature.description).toBe('string') - expect(typeof feature.default).toBe('boolean') - } + for (const name of featureNames) { + const feature = output.features[name] + expect(typeof feature.description).toBe('string') + expect(typeof feature.default).toBe('boolean') } }) it('includes postInstall only for features that declare it', () => { const output = JSON.parse(getInfoOutput()) - for (const stack of stackNames) { - for (const [name, def] of getFeatureEntries(stack)) { - const feature = output.stacks[stack].features[name] - if (def.postInstall) { - expect(feature.postInstall).toEqual(def.postInstall) - } else { - expect(feature).not.toHaveProperty('postInstall') - } - } - } - }) - - it('surfaces requires only for features that declare a dependency', () => { - const output = JSON.parse(getInfoOutput()) - - for (const stack of stackNames) { - for (const [name, def] of getFeatureEntries(stack)) { - const feature = output.stacks[stack].features[name] - if (def.requires) { - expect(feature.requires).toEqual(def.requires) - } else { - expect(feature).not.toHaveProperty('requires') - } + for (const [name, def] of featureEntries) { + const feature = output.features[name] + if (def.postInstall) { + expect(feature.postInstall).toEqual(def.postInstall) + } else { + expect(feature).not.toHaveProperty('postInstall') } } }) @@ -88,57 +43,16 @@ describe('getInfoOutput — no filter', () => { it('does not leak label, packages, or paths into feature output', () => { const output = JSON.parse(getInfoOutput()) - for (const stack of stackNames) { - for (const name of Object.keys(stackDefinitions[stack].features)) { - const feature = output.stacks[stack].features[name] - expect(feature).not.toHaveProperty('label') - expect(feature).not.toHaveProperty('packages') - expect(feature).not.toHaveProperty('paths') - } - } - }) - - it('modes contains full and custom', () => { - const output = JSON.parse(getInfoOutput()) - expect(output.modes).toHaveProperty('full') - expect(output.modes).toHaveProperty('custom') - }) - - it('modes documents the default mode', () => { - const output = JSON.parse(getInfoOutput()) - expect(output.modes).toHaveProperty('default') - }) - - it('lists the modes each stack accepts, so agents never send a rejected one', () => { - const output = JSON.parse(getInfoOutput()) - - for (const stack of stackNames) { - expect(output.stacks[stack].modes).toEqual(getInstallationModes(stack)) + for (const name of featureNames) { + const feature = output.features[name] + expect(feature).not.toHaveProperty('label') + expect(feature).not.toHaveProperty('packages') + expect(feature).not.toHaveProperty('paths') } - - expect(output.stacks.evm.modes).not.toContain('default') }) - it('reports canton as a stack with no features and no modes', () => { + it('documents exactly the full and custom modes', () => { const output = JSON.parse(getInfoOutput()) - - expect(output.stacks.canton.features).toEqual({}) - expect(output.stacks.canton.modes).toEqual([]) - }) -}) - -describe('getInfoOutput — filter by stack', () => { - it('returns only the requested stack', () => { - const output = JSON.parse(getInfoOutput('canton')) - expect(Object.keys(output.stacks)).toEqual(['canton']) - }) - - it('throws on unknown stack filter', () => { - expect(() => getInfoOutput('does-not-exist')).toThrow(/Unknown stack 'does-not-exist'/) - }) - - it('filtering by evm hides canton', () => { - const output = JSON.parse(getInfoOutput('evm')) - expect(Object.keys(output.stacks)).toEqual(['evm']) + expect(Object.keys(output.modes)).toEqual(['full', 'custom']) }) }) diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index d5b0607..0450024 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -1,10 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getFeatureNames } from '../stacks/index.js' +import { featureNames } from '../config.js' vi.mock('../operations/index.js', () => ({ cloneRepo: vi.fn().mockResolvedValue(undefined), createEnvFile: vi.fn().mockResolvedValue(undefined), - createInitialCommit: vi.fn().mockResolvedValue(undefined), installPackages: vi.fn().mockResolvedValue(undefined), cleanupFiles: vi.fn().mockResolvedValue(undefined), })) @@ -20,12 +19,11 @@ vi.mock('../utils/utils.js', async (importOriginal) => { const mockLog = vi.spyOn(console, 'log').mockImplementation(() => {}) const { runNonInteractive } = await import('../nonInteractive.js') -const { cloneRepo, createEnvFile, createInitialCommit, installPackages, cleanupFiles } = - await import('../operations/index.js') +const { cloneRepo, createEnvFile, installPackages, cleanupFiles } = await import( + '../operations/index.js' +) const { projectDirectoryExists } = await import('../utils/utils.js') -const evmFeatureNames = getFeatureNames('evm') - function getLastJsonOutput(): Record { const lastCall = mockLog.mock.calls.at(-1) if (!lastCall) { @@ -55,13 +53,7 @@ describe('nonInteractive — validation', () => { expect(output.error).toBe('Missing required flag: --mode') }) - it('validates stack before --name', async () => { - await expect(runNonInteractive({ stack: 'banana' })).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.error).toMatch(/Invalid stack/) - }) - - it('validates --name before --mode (when stack is valid)', async () => { + it('validates --name before --mode', async () => { await expect(runNonInteractive({})).rejects.toThrow() const output = getLastJsonOutput() expect(output.error).toBe('Missing required flag: --name') @@ -75,10 +67,10 @@ describe('nonInteractive — validation', () => { }) it('rejects invalid mode', async () => { - await expect(runNonInteractive({ name: 'my_app', mode: 'banana' })).rejects.toThrow() + await expect(runNonInteractive({ name: 'my_app', mode: 'default' })).rejects.toThrow() const output = getLastJsonOutput() expect(output.success).toBe(false) - expect(output.error).toMatch(/Invalid mode/) + expect(output.error).toBe("Invalid mode: must be 'full' or 'custom'") }) it('rejects --mode=custom without --features', async () => { @@ -104,22 +96,30 @@ describe('nonInteractive — validation', () => { expect(output.error).toMatch(/--features value is empty/) }) - it('rejects unknown feature names for evm', async () => { + it('rejects unknown feature names', async () => { await expect( runNonInteractive({ name: 'my_app', mode: 'custom', features: 'banana,apple' }), ).rejects.toThrow() const output = getLastJsonOutput() expect(output.success).toBe(false) - expect(output.error).toMatch(/Unknown features for stack 'evm': banana, apple/) + expect(output.error).toMatch(/Unknown features: banana, apple/) expect(output.error).toMatch(/Valid features:/) }) + it('rejects names inherited from Object.prototype', async () => { + await expect( + runNonInteractive({ name: 'my_app', mode: 'custom', features: 'constructor' }), + ).rejects.toThrow() + const output = getLastJsonOutput() + expect(output.error).toMatch(/Unknown features: constructor/) + }) + it('rejects mix of valid and invalid features', async () => { await expect( runNonInteractive({ name: 'my_app', mode: 'custom', features: 'demo,banana' }), ).rejects.toThrow() const output = getLastJsonOutput() - expect(output.error).toMatch(/Unknown features for stack 'evm': banana/) + expect(output.error).toMatch(/Unknown features: banana/) }) it('rejects when project directory already exists (full mode)', async () => { @@ -141,7 +141,7 @@ describe('nonInteractive — validation', () => { }) }) -describe('nonInteractive — evm full mode execution', () => { +describe('nonInteractive — full mode execution', () => { beforeEach(() => { vi.clearAllMocks() process.exitCode = undefined @@ -165,37 +165,25 @@ describe('nonInteractive — evm full mode execution', () => { await runNonInteractive({ name: 'my_app', mode: 'full' }) expect(callOrder).toEqual(['cloneRepo', 'cleanupFiles', 'createEnvFile', 'installPackages']) - expect(createInitialCommit).not.toHaveBeenCalled() }) - it('passes stack as first arg to all operations', async () => { + it('passes the project to all operations', async () => { await runNonInteractive({ name: 'my_app', mode: 'full' }) - expect(cloneRepo).toHaveBeenCalledWith('evm', 'my_app') - expect(createEnvFile).toHaveBeenCalledWith('evm', expect.stringContaining('my_app')) - expect(installPackages).toHaveBeenCalledWith( - 'evm', - expect.stringContaining('my_app'), - 'full', - evmFeatureNames, - ) - expect(cleanupFiles).toHaveBeenCalledWith( - 'evm', - expect.stringContaining('my_app'), - 'full', - evmFeatureNames, - ) - }) - - it('outputs success JSON with stack=evm and all evm features', async () => { + expect(cloneRepo).toHaveBeenCalledWith('my_app') + expect(createEnvFile).toHaveBeenCalledWith(expect.stringContaining('my_app')) + expect(installPackages).toHaveBeenCalledWith(expect.stringContaining('my_app'), featureNames) + expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), featureNames) + }) + + it('outputs success JSON with all features', async () => { await runNonInteractive({ name: 'my_app', mode: 'full' }) const output = getLastJsonOutput() expect(output.success).toBe(true) - expect(output.stack).toBe('evm') expect(output.projectName).toBe('my_app') expect(output.mode).toBe('full') - expect(output.features).toEqual(evmFeatureNames) + expect(output.features).toEqual(featureNames) expect(output.path).toEqual(expect.stringContaining('my_app')) expect(output.postInstall).toBeInstanceOf(Array) }) @@ -213,91 +201,7 @@ describe('nonInteractive — evm full mode execution', () => { await runNonInteractive({ name: 'my_app', mode: 'full', features: 'demo' }) const output = getLastJsonOutput() - expect(output.features).toEqual(evmFeatureNames) - }) -}) - -describe('nonInteractive — canton execution', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('needs only --name, and reports no features', async () => { - await runNonInteractive({ stack: 'canton', name: 'my_app' }) - - expect(cloneRepo).toHaveBeenCalledWith('canton', 'my_app') - - const output = getLastJsonOutput() - expect(output.success).toBe(true) - expect(output.stack).toBe('canton') - expect(output.mode).toBe('full') - expect(output.features).toEqual([]) - }) - - it('rejects --mode, naming the reason', async () => { - await expect( - runNonInteractive({ stack: 'canton', name: 'my_app', mode: 'full' }), - ).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.success).toBe(false) - expect(output.error).toBe( - 'The canton stack has no optional features, so --mode is not accepted', - ) - }) - - it('rejects --features, naming the reason', async () => { - await expect( - runNonInteractive({ stack: 'canton', name: 'my_app', features: 'llm' }), - ).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.error).toBe( - 'The canton stack has no optional features, so --features is not accepted', - ) - }) - - it('still rejects an existing project directory', async () => { - vi.mocked(projectDirectoryExists).mockReturnValueOnce(true) - await expect(runNonInteractive({ stack: 'canton', name: 'my_app' })).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.error).toMatch(/already exists/) - }) - - it('commits the finished canton scaffold, after the install wrote the lockfile', async () => { - const callOrder: string[] = [] - vi.mocked(installPackages).mockImplementation(async () => { - callOrder.push('installPackages') - }) - vi.mocked(createInitialCommit).mockImplementation(async () => { - callOrder.push('createInitialCommit') - }) - - await runNonInteractive({ stack: 'canton', name: 'my_app' }) - - expect(callOrder).toEqual(['installPackages', 'createInitialCommit']) - expect(createInitialCommit).toHaveBeenCalledWith(expect.stringContaining('my_app')) - }) - - it('post-install points at dev-stack.sh and the README', async () => { - await runNonInteractive({ stack: 'canton', name: 'my_app' }) - - const postInstall = getLastJsonOutput().postInstall as string[] - expect(postInstall.some((msg) => msg.includes('dev-stack.sh up'))).toBe(true) - expect(postInstall.some((msg) => msg.includes('README.md'))).toBe(true) - }) -}) - -describe('nonInteractive — default mode', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('rejects --mode default for the evm stack', async () => { - await expect(runNonInteractive({ name: 'my_app', mode: 'default' })).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.success).toBe(false) - expect(output.error).toMatch(/'default' is not available for the evm stack/) + expect(output.features).toEqual(featureNames) }) }) @@ -310,13 +214,11 @@ describe('nonInteractive — custom mode execution', () => { it('passes selected features to operations', async () => { await runNonInteractive({ name: 'my_app', mode: 'custom', features: 'demo,subgraph' }) - expect(installPackages).toHaveBeenCalledWith( - 'evm', - expect.stringContaining('my_app'), - 'custom', - ['demo', 'subgraph'], - ) - expect(cleanupFiles).toHaveBeenCalledWith('evm', expect.stringContaining('my_app'), 'custom', [ + expect(installPackages).toHaveBeenCalledWith(expect.stringContaining('my_app'), [ + 'demo', + 'subgraph', + ]) + expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), [ 'demo', 'subgraph', ]) @@ -446,7 +348,6 @@ describe('nonInteractive — JSON output format', () => { const output = getLastJsonOutput() expect(output).toHaveProperty('success') - expect(output).toHaveProperty('stack') expect(output).toHaveProperty('projectName') expect(output).toHaveProperty('mode') expect(output).toHaveProperty('features') diff --git a/source/__tests__/operations/cleanupFiles.test.ts b/source/__tests__/operations/cleanupFiles.test.ts index 0408cd8..95e519b 100644 --- a/source/__tests__/operations/cleanupFiles.test.ts +++ b/source/__tests__/operations/cleanupFiles.test.ts @@ -1,6 +1,6 @@ import { resolve } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getFeatureNames, stackDefinitions } from '../../stacks/index.js' +import { featureNames } from '../../config.js' vi.mock('node:fs/promises', () => ({ rm: vi.fn().mockResolvedValue(undefined), @@ -12,32 +12,9 @@ vi.mock('../../operations/exec.js', () => ({ execFile: vi.fn().mockResolvedValue(undefined), })) -/** - * Directories in the two templates. Everything else the installer removes is a file, and only - * removed directories drive script stripping. - */ -const TEMPLATE_DIRECTORIES = [ - '.claude', - '.github', - '.husky', - '.install-files', - 'docs', - 'src/subgraphs', - 'src/components/pageComponents/home', - 'canton-connect', - 'canton-dappbooster', - 'canton-theme', - 'kit', -] - -function isTemplateDirectory(target: string): boolean { - return TEMPLATE_DIRECTORIES.some((dir) => target.endsWith(`/${dir}`)) -} - vi.mock('node:fs', () => ({ readFileSync: vi.fn(), writeFileSync: vi.fn(), - statSync: vi.fn((target: string) => ({ isDirectory: () => isTemplateDirectory(target) })), })) const { rm, mkdir, copyFile } = await import('node:fs/promises') @@ -68,9 +45,7 @@ function getWrittenPackageJson(): Record { return JSON.parse(lastCall[1] as string) } -const ALL_EVM_FEATURES = getFeatureNames('evm') - -const EVM_DEV_DEPS = { +const DEV_DEPS = { husky: '^9.1.7', 'lint-staged': '^17.0.4', '@commitlint/cli': '^21.0.1', @@ -78,7 +53,7 @@ const EVM_DEV_DEPS = { vocs: '^1.0.0', } -function mockEvmPackageJson() { +function mockPackageJson() { vi.mocked(readFileSync).mockReturnValue( JSON.stringify({ scripts: { @@ -92,55 +67,20 @@ function mockEvmPackageJson() { prepare: 'husky install', commitlint: 'commitlint --edit', }, - devDependencies: EVM_DEV_DEPS, + devDependencies: DEV_DEPS, }), ) } -/** Mirrors the root package.json of the canton-dappbooster tag the installer clones. */ -const CANTON_SCRIPTS = { - 'app:dev': 'pnpm -C dapp/frontend run dev', - build: 'pnpm -r run --if-present build', - 'check:anatomy': 'node kit/check-anatomy.mjs', - 'check:versions': 'node kit/check-versions.mjs', - 'docs:build': 'typedoc --options kit/typedoc.json', - 'docs:check': 'typedoc --options kit/typedoc.json --emit none && node kit/docs-check.mjs', - knip: 'knip', - lint: 'biome check --error-on-warnings', - prepare: 'husky', - release: "pnpm -r --filter './canton-*' publish --no-git-checks", - 'release:dry': "pnpm -r --filter './canton-*' publish --dry-run --no-git-checks", - 'release:version': 'node kit/release-version.mjs', - test: 'pnpm -r run --if-present test', - typecheck: 'pnpm -r run --if-present typecheck', -} - -const CANTON_DEV_DEPS = { - '@biomejs/biome': '2.5.11', - '@mermaid-js/mermaid-cli': '^11.15.0', - husky: '^9.1.7', - knip: '6.33.0', - 'lint-staged': '^17.0.4', - postcss: '^8.5.26', - typedoc: '^0.28.20', - typescript: '^5.9.3', -} - -function mockCantonPackageJson() { - vi.mocked(readFileSync).mockReturnValue( - JSON.stringify({ scripts: CANTON_SCRIPTS, devDependencies: CANTON_DEV_DEPS }), - ) -} - -describe('cleanupFiles — evm', () => { +describe('cleanupFiles', () => { beforeEach(() => { vi.clearAllMocks() - mockEvmPackageJson() + mockPackageJson() }) - describe('full mode', () => { + describe('every feature kept', () => { it('removes repository metadata and .install-files', async () => { - await cleanupFiles('evm', '/project/my_app', 'full') + await cleanupFiles('/project/my_app', featureNames) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', '.install-files')) @@ -149,7 +89,7 @@ describe('cleanupFiles — evm', () => { }) it('keeps every feature, so package.json is left untouched', async () => { - await cleanupFiles('evm', '/project/my_app', 'full') + await cleanupFiles('/project/my_app', featureNames) expect(getRmPaths()).not.toContain(resolve('/project/my_app', '.husky')) expect(writeFileSync).not.toHaveBeenCalled() @@ -157,30 +97,9 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — all features selected', () => { - it('removes the repository metadata plus .install-files', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ALL_EVM_FEATURES) - - const paths = getRmPaths() - expect(paths).toContain(resolve('/project/my_app', '.install-files')) - expect(paths).toContain(resolve('/project/my_app', '.github')) - }) - - it('leaves package.json alone when there is nothing to strip', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ALL_EVM_FEATURES) - - expect(writeFileSync).not.toHaveBeenCalled() - }) - }) - - describe('custom mode — demo deselected', () => { + describe('demo deselected', () => { it('removes home folder, recreates it, copies replacement', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'subgraph', - 'typedoc', - 'vocs', - 'husky', - ]) + await cleanupFiles('/project/my_app', ['subgraph', 'typedoc', 'vocs', 'husky']) const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home') expect(getRmPaths()).toContain(homeFolder) @@ -194,15 +113,15 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — subgraph deselected', () => { + describe('subgraph deselected', () => { it('removes src/subgraphs', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['demo', 'typedoc', 'vocs', 'husky']) expect(getRmPaths()).toContain(resolve('/project/my_app', 'src/subgraphs')) }) it('cleans up subgraph demos when demo IS selected', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['demo', 'typedoc', 'vocs', 'husky']) const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home') expect(getRmPaths()).toContain(resolve(homeFolder, 'Examples/demos/subgraphs')) @@ -216,7 +135,7 @@ describe('cleanupFiles — evm', () => { }) it('does NOT clean up subgraph demos when demo is also deselected', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['typedoc', 'vocs', 'husky']) const subgraphDemosPath = resolve( '/project/my_app', @@ -226,7 +145,7 @@ describe('cleanupFiles — evm', () => { }) it('removes subgraph-codegen from package.json scripts', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['demo', 'typedoc', 'vocs', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -234,15 +153,15 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — typedoc deselected', () => { + describe('typedoc deselected', () => { it('removes typedoc.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'subgraph', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'vocs', 'husky']) expect(getRmPaths()).toContain(resolve('/project/my_app', 'typedoc.json')) }) it('removes typedoc:build from package.json scripts', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'subgraph', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'vocs', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -250,26 +169,16 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — vocs deselected', () => { + describe('vocs deselected', () => { it('removes vocs.config.ts and docs folder', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'demo', - 'subgraph', - 'typedoc', - 'husky', - ]) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'typedoc', 'husky']) expect(getRmPaths()).toContain(resolve('/project/my_app', 'vocs.config.ts')) expect(getRmPaths()).toContain(resolve('/project/my_app', 'docs')) }) it('removes docs scripts from package.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'demo', - 'subgraph', - 'typedoc', - 'husky', - ]) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'typedoc', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -279,14 +188,9 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — husky deselected', () => { + describe('husky deselected', () => { it('removes husky folder and config files', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'demo', - 'subgraph', - 'typedoc', - 'vocs', - ]) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'typedoc', 'vocs']) expect(getRmPaths()).toContain(resolve('/project/my_app', '.husky')) expect(getRmPaths()).toContain(resolve('/project/my_app', '.lintstagedrc.mjs')) @@ -294,12 +198,7 @@ describe('cleanupFiles — evm', () => { }) it('removes the tooling scripts from package.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'demo', - 'subgraph', - 'typedoc', - 'vocs', - ]) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'typedoc', 'vocs']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -308,12 +207,7 @@ describe('cleanupFiles — evm', () => { }) it('leaves the dependencies to the package manager', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', [ - 'demo', - 'subgraph', - 'typedoc', - 'vocs', - ]) + await cleanupFiles('/project/my_app', ['demo', 'subgraph', 'typedoc', 'vocs']) const devDeps = getWrittenPackageJson().devDependencies as Record expect(devDeps.husky).toBe('^9.1.7') @@ -321,9 +215,9 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — husky selected', () => { + describe('husky selected', () => { it('keeps the husky files, scripts, and dependencies', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ALL_EVM_FEATURES) + await cleanupFiles('/project/my_app', featureNames) const paths = getRmPaths() expect(paths).not.toContain(resolve('/project/my_app', '.husky')) @@ -333,9 +227,9 @@ describe('cleanupFiles — evm', () => { }) }) - describe('custom mode — no features selected', () => { + describe('no features kept', () => { it('runs all cleanup operations', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', []) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', 'src/components/pageComponents/home')) @@ -347,7 +241,7 @@ describe('cleanupFiles — evm', () => { }) it('removes all optional scripts from package.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', []) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -363,14 +257,14 @@ describe('cleanupFiles — evm', () => { }) it('always removes .install-files as the last rm call', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo']) + await cleanupFiles('/project/my_app', ['demo']) const paths = getRmPaths() expect(paths.at(-1)).toBe(resolve('/project/my_app', '.install-files')) }) it('uses force option on all rm calls', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', []) for (const call of vi.mocked(rm).mock.calls) { const options = call[1] as { force?: boolean } @@ -379,16 +273,16 @@ describe('cleanupFiles — evm', () => { }) describe('onProgress callback', () => { - it('reports the prepare and install-script steps for full mode', async () => { + it('reports the prepare and install-script steps when every feature is kept', async () => { const steps: string[] = [] - await cleanupFiles('evm', '/project/my_app', 'full', [], (step) => steps.push(step)) + await cleanupFiles('/project/my_app', featureNames, (step) => steps.push(step)) expect(steps).toEqual(['Repository metadata', 'Install script']) }) it('reports all feature cleanups when no features selected', async () => { const steps: string[] = [] - await cleanupFiles('evm', '/project/my_app', 'custom', [], (step) => steps.push(step)) + await cleanupFiles('/project/my_app', [], (step) => steps.push(step)) expect(steps).toEqual([ 'Repository metadata', @@ -403,9 +297,7 @@ describe('cleanupFiles — evm', () => { it('skips steps for selected features', async () => { const steps: string[] = [] - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'subgraph'], (step) => - steps.push(step), - ) + await cleanupFiles('/project/my_app', ['demo', 'subgraph'], (step) => steps.push(step)) expect(steps).not.toContain('Component Demos') expect(steps).not.toContain('Subgraph support') @@ -414,117 +306,7 @@ describe('cleanupFiles — evm', () => { }) it('works without a callback', async () => { - await expect(cleanupFiles('evm', '/project/my_app', 'full')).resolves.toBeUndefined() + await expect(cleanupFiles('/project/my_app', featureNames)).resolves.toBeUndefined() }) }) }) - -describe('cleanupFiles — canton', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCantonPackageJson() - }) - - it('removes every path the stack lists, libraries and repo tooling alike', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const paths = getRmPaths() - for (const relativePath of stackDefinitions.canton.prepare.paths) { - expect(paths).toContain(resolve('/project/my_app', relativePath)) - } - }) - - it('deletes the lockfile, so the install resolves the libraries from npm', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - expect(getRmPaths()).toContain(resolve('/project/my_app', 'pnpm-lock.yaml')) - }) - - it('keeps the hygiene tooling and the deployment example the project still wants', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const paths = getRmPaths() - for (const kept of [ - 'biome.json', - 'knip.json', - 'commitlint.config.js', - '.husky', - 'README.md', - 'scripts', - 'dapp/daml', - 'dapp/frontend/vercel.json', - ]) { - expect(paths).not.toContain(resolve('/project/my_app', kept)) - } - }) - - it('removes the scripts that run the deleted kit folder', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const scripts = getWrittenPackageJson().scripts as Record - for (const name of [ - 'check:anatomy', - 'check:versions', - 'docs:build', - 'docs:check', - 'release:version', - ]) { - expect(scripts[name]).toBeUndefined() - } - }) - - it('removes the declared scripts that no deleted path names', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const scripts = getWrittenPackageJson().scripts as Record - for (const name of stackDefinitions.canton.prepare.scripts) { - expect(scripts[name]).toBeUndefined() - } - }) - - it('keeps the scripts the project still needs', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const scripts = getWrittenPackageJson().scripts as Record - expect(scripts.lint).toBe('biome check --error-on-warnings') - expect(scripts.knip).toBe('knip') - expect(scripts.prepare).toBe('husky') - expect(scripts['app:dev']).toBe('pnpm -C dapp/frontend run dev') - }) - - it('removes the dev-dependencies that only served the deleted tooling', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - const devDeps = getWrittenPackageJson().devDependencies as Record - for (const name of stackDefinitions.canton.prepare.devDependencies) { - expect(devDeps[name]).toBeUndefined() - } - expect(devDeps.knip).toBe('6.33.0') - expect(devDeps.husky).toBe('^9.1.7') - }) - - it('never restores a staged file: the stack ships no staging directory', async () => { - await cleanupFiles('canton', '/project/my_app', 'full') - - expect(copyFile).not.toHaveBeenCalled() - expect(mkdir).not.toHaveBeenCalled() - }) - - it('has no features, so the mode changes nothing', async () => { - await cleanupFiles('canton', '/project/my_app', 'custom', []) - const custom = getRmPaths() - - vi.clearAllMocks() - mockCantonPackageJson() - - await cleanupFiles('canton', '/project/my_app', 'full') - expect(getRmPaths()).toEqual(custom) - }) - - it('reports one progress step, since there are no features to report', async () => { - const steps: string[] = [] - await cleanupFiles('canton', '/project/my_app', 'full', [], (step) => steps.push(step)) - - expect(steps).toEqual([stackDefinitions.canton.prepare.label]) - }) -}) diff --git a/source/__tests__/operations/cloneRepo.test.ts b/source/__tests__/operations/cloneRepo.test.ts index 51d30a4..a6a4455 100644 --- a/source/__tests__/operations/cloneRepo.test.ts +++ b/source/__tests__/operations/cloneRepo.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { stackDefinitions } from '../../stacks/index.js' vi.mock('../../operations/exec.js', () => ({ exec: vi.fn().mockResolvedValue(undefined), @@ -10,40 +9,32 @@ vi.mock('node:fs/promises', () => ({ rm: vi.fn().mockResolvedValue(undefined), })) -vi.mock('../../utils/utils.js', async (importOriginal) => { - const actual = await importOriginal() - return { ...actual, meetsNodeVersion: vi.fn().mockReturnValue(true) } -}) - const { exec, execFile } = await import('../../operations/exec.js') const { rm } = await import('node:fs/promises') -const { meetsNodeVersion } = await import('../../utils/utils.js') const { cloneRepo } = await import('../../operations/cloneRepo.js') -const evmRepoUrl = stackDefinitions.evm.repoUrl -const cantonRepoUrl = stackDefinitions.canton.repoUrl +const repoUrl = 'https://github.com/BootNodeDev/dAppBooster.git' -describe('cloneRepo — evm (tag-latest)', () => { +describe('cloneRepo — latest tag', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(meetsNodeVersion).mockReturnValue(true) }) - it('clones with execFile using evm repo url and projectName as arg', async () => { - await cloneRepo('evm', 'my_app') + it('clones with execFile using the repo url and projectName as arg', async () => { + await cloneRepo('my_app') expect(execFile).toHaveBeenCalledWith('git', [ 'clone', '--depth', '1', '--no-checkout', - evmRepoUrl, + repoUrl, 'my_app', ]) }) it('fetches tags with execFile', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') expect(execFile).toHaveBeenCalledWith('git', ['fetch', '--tags'], { cwd: expect.stringContaining('my_app'), @@ -51,7 +42,7 @@ describe('cloneRepo — evm (tag-latest)', () => { }) it('checks out latest tag with exec (needs shell)', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') expect(exec).toHaveBeenCalledWith(expect.stringContaining('git checkout $(git describe'), { cwd: expect.stringContaining('my_app'), @@ -59,7 +50,7 @@ describe('cloneRepo — evm (tag-latest)', () => { }) it('removes .git with fs.rm', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') expect(rm).toHaveBeenCalledWith(expect.stringContaining('my_app/.git'), { recursive: true, @@ -68,7 +59,7 @@ describe('cloneRepo — evm (tag-latest)', () => { }) it('initializes fresh git repo with execFile', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') expect(execFile).toHaveBeenCalledWith('git', ['init'], { cwd: expect.stringContaining('my_app'), @@ -76,7 +67,7 @@ describe('cloneRepo — evm (tag-latest)', () => { }) it('does not interpolate projectName into shell strings', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') for (const call of vi.mocked(exec).mock.calls) { expect(call[0]).not.toContain('my_app') @@ -85,10 +76,10 @@ describe('cloneRepo — evm (tag-latest)', () => { it('reports the canonical 5 progress steps in order', async () => { const steps: string[] = [] - await cloneRepo('evm', 'my_app', (step) => steps.push(step)) + await cloneRepo('my_app', (step) => steps.push(step)) expect(steps).toEqual([ - 'Cloning EVM in my_app', + 'Cloning dAppBooster in my_app', 'Fetching tags', 'Checking out latest tag', 'Removing .git folder', @@ -96,80 +87,29 @@ describe('cloneRepo — evm (tag-latest)', () => { ]) }) - it('declares no Node floor, so the version is never checked', async () => { - await cloneRepo('evm', 'my_app') - - expect(meetsNodeVersion).not.toHaveBeenCalled() - }) - it('checks the package manager is on PATH before cloning', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') const calls = vi.mocked(execFile).mock.calls - expect(calls[0]).toEqual([stackDefinitions.evm.packageManager, ['--version']]) + expect(calls[0]).toEqual(['pnpm', ['--version']]) }) it('fails with a plain message when the package manager is missing', async () => { vi.mocked(execFile).mockRejectedValueOnce(new Error('spawn pnpm ENOENT')) - await expect(cloneRepo('evm', 'my_app')).rejects.toThrow(/pnpm was not found/) + await expect(cloneRepo('my_app')).rejects.toThrow(/pnpm was not found/) expect(execFile).toHaveBeenCalledTimes(1) }) it('works without a callback', async () => { - await expect(cloneRepo('evm', 'my_app')).resolves.toBeUndefined() - }) -}) - -describe('cloneRepo — canton (tag-latest)', () => { - beforeEach(() => { - vi.clearAllMocks() - vi.mocked(meetsNodeVersion).mockReturnValue(true) - }) - - it('clones the canton repo and checks out its latest tag', async () => { - await cloneRepo('canton', 'my_app') - - expect(execFile).toHaveBeenCalledWith('git', [ - 'clone', - '--depth', - '1', - '--no-checkout', - cantonRepoUrl, - 'my_app', - ]) - expect(execFile).toHaveBeenCalledWith('git', ['fetch', '--tags'], expect.anything()) - }) - - it('reinitializes git with execFile', async () => { - await cloneRepo('canton', 'my_app') - - expect(execFile).toHaveBeenCalledWith('git', ['init'], { - cwd: expect.stringContaining('my_app'), - }) - }) - - it('checks the running Node against the stack floor before cloning', async () => { - await cloneRepo('canton', 'my_app') - - expect(meetsNodeVersion).toHaveBeenCalledWith(stackDefinitions.canton.minNodeVersion) - }) - - it('fails with a plain message on too old a Node, before touching the disk', async () => { - vi.mocked(meetsNodeVersion).mockReturnValue(false) - - await expect(cloneRepo('canton', 'my_app')).rejects.toThrow( - /Canton stack needs Node 24\.15\.0 or later/, - ) - expect(execFile).not.toHaveBeenCalled() + await expect(cloneRepo('my_app')).resolves.toBeUndefined() }) }) -describe('cloneRepo — tag-latest stack pinned to one ref', () => { +describe('cloneRepo — pinned to one ref', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(meetsNodeVersion).mockReturnValue(true) - vi.stubEnv('DAPPBOOSTER_EVM_REF', 'v1.2.3') + vi.stubEnv('DAPPBOOSTER_REF', 'v1.2.3') }) afterEach(() => { @@ -177,7 +117,7 @@ describe('cloneRepo — tag-latest stack pinned to one ref', () => { }) it('fetches that ref shallowly and checks it out instead of the latest tag', async () => { - await cloneRepo('evm', 'my_app') + await cloneRepo('my_app') expect(execFile).toHaveBeenCalledWith('git', ['fetch', '--depth', '1', 'origin', 'v1.2.3'], { cwd: expect.stringContaining('my_app'), @@ -190,10 +130,10 @@ describe('cloneRepo — tag-latest stack pinned to one ref', () => { it('names the ref in its progress steps', async () => { const steps: string[] = [] - await cloneRepo('evm', 'my_app', (step) => steps.push(step)) + await cloneRepo('my_app', (step) => steps.push(step)) expect(steps).toEqual([ - 'Cloning EVM (v1.2.3) in my_app', + 'Cloning dAppBooster (v1.2.3) in my_app', 'Checking out v1.2.3', 'Removing .git folder', 'Initializing Git repository', diff --git a/source/__tests__/operations/createEnvFile.test.ts b/source/__tests__/operations/createEnvFile.test.ts index 9200de6..01b4765 100644 --- a/source/__tests__/operations/createEnvFile.test.ts +++ b/source/__tests__/operations/createEnvFile.test.ts @@ -7,13 +7,13 @@ vi.mock('node:fs/promises', () => ({ const { copyFile } = await import('node:fs/promises') const { createEnvFile } = await import('../../operations/createEnvFile.js') -describe('createEnvFile — evm', () => { +describe('createEnvFile', () => { beforeEach(() => { vi.clearAllMocks() }) it('copies .env.example to .env.local in the project folder', async () => { - await createEnvFile('evm', '/project/my_app') + await createEnvFile('/project/my_app') expect(copyFile).toHaveBeenCalledWith( '/project/my_app/.env.example', @@ -22,7 +22,7 @@ describe('createEnvFile — evm', () => { }) it('uses the provided project folder for both paths', async () => { - await createEnvFile('evm', '/other/path') + await createEnvFile('/other/path') expect(copyFile).toHaveBeenCalledWith('/other/path/.env.example', '/other/path/.env.local') }) @@ -30,19 +30,6 @@ describe('createEnvFile — evm', () => { it('propagates errors from copyFile', async () => { vi.mocked(copyFile).mockRejectedValueOnce(new Error('.env.example not found')) - await expect(createEnvFile('evm', '/project/my_app')).rejects.toThrow('.env.example not found') - }) -}) - -describe('createEnvFile — canton', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('copies the single root .env.example to .env', async () => { - await createEnvFile('canton', '/project/my_app') - - expect(copyFile).toHaveBeenCalledTimes(1) - expect(copyFile).toHaveBeenCalledWith('/project/my_app/.env.example', '/project/my_app/.env') + await expect(createEnvFile('/project/my_app')).rejects.toThrow('.env.example not found') }) }) diff --git a/source/__tests__/operations/createInitialCommit.test.ts b/source/__tests__/operations/createInitialCommit.test.ts deleted file mode 100644 index db07423..0000000 --- a/source/__tests__/operations/createInitialCommit.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('../../operations/exec.js', () => ({ - exec: vi.fn().mockResolvedValue(undefined), - execFile: vi.fn().mockResolvedValue(undefined), -})) - -const { exec, execFile } = await import('../../operations/exec.js') -const { createInitialCommit } = await import('../../operations/createInitialCommit.js') - -describe('createInitialCommit', () => { - beforeEach(() => { - vi.clearAllMocks() - }) - - it('stages everything, then commits, in that order', async () => { - await createInitialCommit('/project/my_app') - - const commands = vi.mocked(execFile).mock.calls.map((call) => (call[1] as string[]).join(' ')) - expect(commands[0]).toBe('add .') - expect(commands[1]).toContain('commit') - }) - - it('runs in the project folder', async () => { - await createInitialCommit('/project/my_app') - - for (const call of vi.mocked(execFile).mock.calls) { - expect(call[2]).toEqual({ cwd: '/project/my_app' }) - } - }) - - it('skips the project hooks, which would lint a tree the user has not touched', async () => { - await createInitialCommit('/project/my_app') - - const commitCall = vi - .mocked(execFile) - .mock.calls.find((call) => (call[1] as string[]).includes('commit')) - expect(commitCall?.[1]).toContain('--no-verify') - }) - - it("commits with the user's own git identity, without signing", async () => { - await createInitialCommit('/project/my_app') - - const commitArgs = vi - .mocked(execFile) - .mock.calls.find((call) => (call[1] as string[]).includes('commit'))?.[1] as string[] - - expect(commitArgs.join(' ')).not.toContain('user.name') - expect(commitArgs.join(' ')).not.toContain('user.email') - expect(commitArgs).toContain('commit.gpgsign=false') - expect(commitArgs).toContain('chore: initial commit') - }) - - it('never uses a shell', async () => { - await createInitialCommit('/project/my_app') - - expect(exec).not.toHaveBeenCalled() - }) -}) diff --git a/source/__tests__/operations/installPackages.test.ts b/source/__tests__/operations/installPackages.test.ts index 87f97a4..c2caf61 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -1,64 +1,36 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { getFeatureNames, stackDefinitions } from '../../stacks/index.js' +import { featureDefinitions, featureNames } from '../../config.js' vi.mock('../../operations/exec.js', () => ({ exec: vi.fn().mockResolvedValue(undefined), execFile: vi.fn().mockResolvedValue(undefined), })) -vi.mock('node:fs', () => ({ readFileSync: vi.fn() })) - const { execFile } = await import('../../operations/exec.js') -const { readFileSync } = await import('node:fs') const { installPackages } = await import('../../operations/installPackages.js') -/** The EVM template ships a postinstall script; the Canton one does not. */ -function mockPackageJson(scripts: Record = { postinstall: 'wagmi generate' }) { - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ scripts })) -} - -const evmFeatures = stackDefinitions.evm.features - -describe('installPackages — evm', () => { +describe('installPackages', () => { beforeEach(() => { vi.clearAllMocks() - mockPackageJson() }) - describe('full mode', () => { + describe('every feature kept', () => { it('runs pnpm install via execFile', async () => { - await installPackages('evm', '/project/my_app', 'full') + await installPackages('/project/my_app', featureNames) expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) }) it('runs only one command', async () => { - await installPackages('evm', '/project/my_app', 'full') + await installPackages('/project/my_app', featureNames) expect(execFile).toHaveBeenCalledTimes(1) }) - - it('ignores features argument', async () => { - await installPackages('evm', '/project/my_app', 'full', ['demo', 'subgraph']) - - expect(execFile).toHaveBeenCalledTimes(1) - expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) - }) }) - describe('custom mode — all features selected', () => { - it('runs pnpm install when no packages to remove', async () => { - const allFeatures = getFeatureNames('evm') - await installPackages('evm', '/project/my_app', 'custom', allFeatures) - - expect(execFile).toHaveBeenCalledTimes(1) - expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) - }) - }) - - describe('custom mode — some features deselected', () => { + describe('some features deselected', () => { it('runs pnpm remove with deselected feature packages', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) const removeCall = vi .mocked(execFile) @@ -66,10 +38,10 @@ describe('installPackages — evm', () => { expect(removeCall).toBeDefined() const removeArgs = removeCall?.[1] as string[] - for (const pkg of evmFeatures.subgraph.packages) { + for (const pkg of featureDefinitions.subgraph.packages) { expect(removeArgs).toContain(pkg) } - for (const pkg of evmFeatures.typedoc.packages) { + for (const pkg of featureDefinitions.typedoc.packages) { expect(removeArgs).toContain(pkg) } }) @@ -80,18 +52,18 @@ describe('installPackages — evm', () => { if (args[0] === 'remove') { callOrder.push('remove') } - if (args[0] === 'run' && args[1] === 'postinstall') { + if (args[0] === 'run' && args.includes('postinstall')) { callOrder.push('postinstall') } }) - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) expect(callOrder).toEqual(['remove', 'postinstall']) }) it('does not include selected feature packages in remove command', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo', 'subgraph']) + await installPackages('/project/my_app', ['demo', 'subgraph']) const removeCall = vi .mocked(execFile) @@ -99,13 +71,13 @@ describe('installPackages — evm', () => { expect(removeCall).toBeDefined() const removeArgs = removeCall?.[1] as string[] - for (const pkg of evmFeatures.subgraph.packages) { + for (const pkg of featureDefinitions.subgraph.packages) { expect(removeArgs).not.toContain(pkg) } }) it('uses execFile for pnpm remove to avoid shell interpolation', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) expect(execFile).toHaveBeenCalledWith('pnpm', expect.arrayContaining(['remove']), { cwd: '/project/my_app', @@ -113,7 +85,7 @@ describe('installPackages — evm', () => { }) it('passes each package as a separate arg to execFile', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) const removeCall = vi .mocked(execFile) @@ -122,15 +94,15 @@ describe('installPackages — evm', () => { const removeArgs = removeCall?.[1] as string[] expect(removeArgs[0]).toBe('remove') - for (const pkg of evmFeatures.subgraph.packages) { + for (const pkg of featureDefinitions.subgraph.packages) { expect(removeArgs).toContain(pkg) } }) it('runs postinstall via execFile', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) - expect(execFile).toHaveBeenCalledWith('pnpm', ['run', 'postinstall'], { + expect(execFile).toHaveBeenCalledWith('pnpm', ['run', '--if-present', 'postinstall'], { cwd: '/project/my_app', }) }) @@ -139,67 +111,28 @@ describe('installPackages — evm', () => { it('never uses exec (shell) for any command', async () => { const { exec } = await import('../../operations/exec.js') - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) expect(exec).not.toHaveBeenCalled() }) describe('onProgress callback', () => { - it('reports one step for full mode', async () => { + it('reports one step when every feature is kept', async () => { const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'full', [], (step) => steps.push(step)) + await installPackages('/project/my_app', featureNames, (step) => steps.push(step)) expect(steps).toEqual(['Installing packages']) }) - it('reports two steps for custom mode with packages to remove', async () => { + it('reports two steps when packages are removed', async () => { const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'custom', ['demo'], (step) => - steps.push(step), - ) + await installPackages('/project/my_app', ['demo'], (step) => steps.push(step)) expect(steps).toEqual(['Installing packages', 'Executing post-install scripts']) }) - it('reports one step for custom mode with all features selected', async () => { - const allFeatures = getFeatureNames('evm') - const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'custom', allFeatures, (step) => - steps.push(step), - ) - - expect(steps).toEqual(['Installing packages']) - }) - it('works without a callback', async () => { - await expect(installPackages('evm', '/project/my_app', 'full')).resolves.toBeUndefined() + await expect(installPackages('/project/my_app', featureNames)).resolves.toBeUndefined() }) }) }) - -describe('installPackages — canton', () => { - beforeEach(() => { - vi.clearAllMocks() - mockPackageJson({}) - }) - - it('runs a single pnpm install: the stack has no packages to remove', async () => { - await installPackages('canton', '/project/my_app', 'full') - - expect(execFile).toHaveBeenCalledTimes(1) - expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) - }) - - it('installs the same way whatever the mode, since nothing is optional', async () => { - await installPackages('canton', '/project/my_app', 'custom', []) - - expect(execFile).toHaveBeenCalledTimes(1) - expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) - }) - - it('skips the postinstall script the canton template does not have', async () => { - await installPackages('canton', '/project/my_app', 'custom', []) - - expect(execFile).not.toHaveBeenCalledWith('pnpm', ['run', 'postinstall'], expect.anything()) - }) -}) diff --git a/source/__tests__/utils.test.ts b/source/__tests__/utils.test.ts index a9b3941..3adeb6a 100644 --- a/source/__tests__/utils.test.ts +++ b/source/__tests__/utils.test.ts @@ -1,26 +1,15 @@ import { describe, expect, it } from 'vitest' +import { featureDefinitions, featureEntries, featureNames } from '../config.js' import { - getDefaultFeatureNames, - getFeatureEntries, - getFeatureNames, - stackDefinitions, - stackNames, -} from '../stacks/index.js' -import { - applyFeatureToggle, deriveStepDisplay, describeInstallPlan, getPackagesToRemove, getPostInstallMessages, isFeatureSelected, isValidName, - meetsNodeVersion, resolveModeFeatures, - resolveSelectedFeatures, } from '../utils/utils.js' -const evmFeatures = stackDefinitions.evm.features - describe('isValidName', () => { it('accepts alphanumeric names', () => { expect(isValidName('myApp')).toBe(true) @@ -78,123 +67,74 @@ describe('isFeatureSelected', () => { }) }) -describe('getPackagesToRemove — evm', () => { +describe('getPackagesToRemove', () => { it('returns empty when all features selected', () => { - const allFeatures = getFeatureNames('evm') - expect(getPackagesToRemove('evm', allFeatures)).toEqual([]) + const allFeatures = featureNames + expect(getPackagesToRemove(allFeatures)).toEqual([]) }) it('returns all packages when no features selected', () => { - const result = getPackagesToRemove('evm', []) + const result = getPackagesToRemove([]) - const allPackages = Object.values(evmFeatures).flatMap((def) => def.packages) + const allPackages = Object.values(featureDefinitions).flatMap((def) => def.packages) expect(result).toEqual(allPackages) }) it('returns packages only for deselected features', () => { - const result = getPackagesToRemove('evm', ['demo', 'subgraph']) + const result = getPackagesToRemove(['demo', 'subgraph']) - for (const pkg of evmFeatures.subgraph.packages) { + for (const pkg of featureDefinitions.subgraph.packages) { expect(result).not.toContain(pkg) } - for (const pkg of evmFeatures.typedoc.packages) { + for (const pkg of featureDefinitions.typedoc.packages) { expect(result).toContain(pkg) } }) it('handles demo (which has no packages) correctly', () => { - const withDemo = getPackagesToRemove('evm', ['demo']) - const withoutDemo = getPackagesToRemove('evm', []) + const withDemo = getPackagesToRemove(['demo']) + const withoutDemo = getPackagesToRemove([]) expect(withDemo).toEqual(withoutDemo) }) }) -describe('getPackagesToRemove — canton', () => { - it('returns empty: the stack has no features, so nothing is optional', () => { - expect(getPackagesToRemove('canton', [])).toEqual([]) - }) -}) - -describe('meetsNodeVersion', () => { - it('accepts the exact version', () => { - expect(meetsNodeVersion('24.15.0', '24.15.0')).toBe(true) - }) - - it('accepts a newer minor or patch', () => { - expect(meetsNodeVersion('24.15.0', '24.20.0')).toBe(true) - expect(meetsNodeVersion('24.15.0', '24.15.3')).toBe(true) - }) - - it('accepts a newer major even with a lower minor', () => { - expect(meetsNodeVersion('24.15.0', '25.0.0')).toBe(true) - }) - - it('rejects an older major, minor, or patch', () => { - expect(meetsNodeVersion('24.15.0', '22.20.0')).toBe(false) - expect(meetsNodeVersion('24.15.0', '24.14.9')).toBe(false) - expect(meetsNodeVersion('24.15.1', '24.15.0')).toBe(false) - }) -}) - describe('getPostInstallMessages', () => { - it('returns every evm message for a scaffold that kept every feature', () => { - const result = getPostInstallMessages('evm', getFeatureNames('evm')) + it('returns every message for a scaffold that kept every feature', () => { + const result = getPostInstallMessages(featureNames) - const allMessages = getFeatureEntries('evm').flatMap(([, def]) => def.postInstall ?? []) + const allMessages = featureEntries.flatMap(([, def]) => def.postInstall ?? []) expect(result).toEqual(allMessages) }) it('returns only the kept features messages', () => { - const result = getPostInstallMessages('evm', ['subgraph']) + const result = getPostInstallMessages(['subgraph']) - expect(result).toEqual(evmFeatures.subgraph.postInstall) + expect(result).toEqual(featureDefinitions.subgraph.postInstall) }) it('returns empty when the kept features carry no guidance', () => { - const result = getPostInstallMessages('evm', ['demo']) + const result = getPostInstallMessages(['demo']) expect(result).toEqual([]) }) it('returns empty when nothing was kept', () => { - const result = getPostInstallMessages('evm', []) + const result = getPostInstallMessages([]) expect(result).toEqual([]) }) - - it('returns the canton stack-level guidance, which no feature adds to', () => { - const result = getPostInstallMessages('canton', []) - - expect(result).toEqual(stackDefinitions.canton.postInstall) - }) -}) - -describe('resolveSelectedFeatures — evm (no requires)', () => { - it('returns the selection unchanged, in config order', () => { - expect(resolveSelectedFeatures('evm', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph']) - }) }) describe('describeInstallPlan', () => { it('returns one labelled setting per line, naming the mode as the selector did', () => { - expect(describeInstallPlan('evm', 'my_app', 'full', [])).toEqual([ - { label: 'Stack', value: 'EVM' }, + expect(describeInstallPlan('my_app', 'full', [])).toEqual([ { label: 'Project', value: 'my_app' }, { label: 'Mode', value: 'Full' }, ]) }) - it('names the default mode as the selector did', () => { - expect(describeInstallPlan('evm', 'my_app', 'default', [])).toEqual([ - { label: 'Stack', value: 'EVM' }, - { label: 'Project', value: 'my_app' }, - { label: 'Mode', value: 'Default (recommended)' }, - ]) - }) - it('lists the selected features for a custom-mode plan', () => { - expect(describeInstallPlan('evm', 'my_app', 'custom', ['demo', 'subgraph'])).toEqual([ - { label: 'Stack', value: 'EVM' }, + expect(describeInstallPlan('my_app', 'custom', ['demo', 'subgraph'])).toEqual([ { label: 'Project', value: 'my_app' }, { label: 'Mode', value: 'Custom' }, { label: 'Features', value: 'demo, subgraph' }, @@ -202,8 +142,7 @@ describe('describeInstallPlan', () => { }) it('shows "none" when a custom plan selects no features', () => { - expect(describeInstallPlan('evm', 'demo_app', 'custom', [])).toEqual([ - { label: 'Stack', value: 'EVM' }, + expect(describeInstallPlan('demo_app', 'custom', [])).toEqual([ { label: 'Project', value: 'demo_app' }, { label: 'Mode', value: 'Custom' }, { label: 'Features', value: 'none' }, @@ -213,36 +152,11 @@ describe('describeInstallPlan', () => { describe('resolveModeFeatures', () => { it('returns all features for full mode', () => { - expect(resolveModeFeatures('evm', 'full')).toEqual(getFeatureNames('evm')) - }) - - it('returns the default:true set for default mode', () => { - expect(resolveModeFeatures('evm', 'default')).toEqual(getDefaultFeatureNames('evm')) - }) - - it('resolves requires for default mode too, not only for custom', () => { - for (const stack of stackNames) { - expect(resolveModeFeatures(stack, 'default')).toEqual( - resolveSelectedFeatures(stack, getDefaultFeatureNames(stack)), - ) - } - }) - - it('returns nothing for a stack with no features, whatever the mode', () => { - expect(resolveModeFeatures('canton', 'full')).toEqual([]) - expect(resolveModeFeatures('canton', 'custom', [])).toEqual([]) - }) -}) - -describe('applyFeatureToggle — evm (no dependencies)', () => { - it('selecting a feature adds it in config order', () => { - expect(applyFeatureToggle('evm', ['subgraph'], 'demo', 'select')).toEqual(['demo', 'subgraph']) + expect(resolveModeFeatures('full')).toEqual(featureNames) }) - it('unselecting a feature removes only that feature', () => { - expect(applyFeatureToggle('evm', ['demo', 'subgraph', 'vocs'], 'subgraph', 'unselect')).toEqual( - ['demo', 'vocs'], - ) + it('returns the custom selection in config order', () => { + expect(resolveModeFeatures('custom', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph']) }) }) diff --git a/source/app.tsx b/source/app.tsx index 9c831c7..420a433 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -9,17 +9,10 @@ import InstallationMode from './components/steps/InstallationMode.js' import OptionalPackages from './components/steps/OptionalPackages.js' import PostInstall from './components/steps/PostInstall.js' import ProjectName from './components/steps/ProjectName.js' -import StackSelection from './components/steps/StackSelection.js' -import { getInstallationModes } from './stacks/index.js' -import type { FeatureName, InstallationSelectItem, MultiSelectItem, Stack } from './types/types.js' +import type { FeatureName, InstallationSelectItem, MultiSelectItem } from './types/types.js' import { canShowStep, describeInstallPlan, resolveModeFeatures } from './utils/utils.js' -interface Props { - preselectedStack?: Stack -} - -const App: FC = ({ preselectedStack }) => { - const [stack, setStack] = useState(preselectedStack) +const App: FC = () => { const [projectName, setProjectName] = useState('') const [currentStep, setCurrentStep] = useState(1) const [setupType, setSetupType] = useState() @@ -27,7 +20,6 @@ const App: FC = ({ preselectedStack }) => { const [attempt, setAttempt] = useState(0) const finishStep = useCallback(() => setCurrentStep((prevStep) => prevStep + 1), []) - const onSelectStack = useCallback((value: Stack) => setStack(value), []) const onSelectSetupType = useCallback((item: InstallationSelectItem) => setSetupType(item), []) const onSelectSelectedFeatures = useCallback( (selectedItems: Array) => setSelectedFeatures([...selectedItems]), @@ -38,135 +30,84 @@ const App: FC = ({ preselectedStack }) => { setProjectName('') setSetupType(undefined) setSelectedFeatures(undefined) - setStack(preselectedStack) setCurrentStep(1) setAttempt((prev) => prev + 1) - }, [preselectedStack]) + }, []) - const skipFeatures = setupType?.value === 'full' || setupType?.value === 'default' + const skipFeatures = setupType?.value === 'full' const mode = setupType?.value ?? 'full' const features = useMemo(() => { - if (stack === undefined) { - return [] - } - const selectedNames = selectedFeatures?.map((item) => item.value as FeatureName) ?? [] - return resolveModeFeatures(stack, mode, selectedNames) - }, [stack, mode, selectedFeatures]) + return resolveModeFeatures(mode, selectedNames) + }, [mode, selectedFeatures]) // Memoised because it feeds the step list below, which would otherwise rebuild on every render. const planSummary = useMemo( - () => (stack === undefined ? [] : describeInstallPlan(stack, projectName, mode, features)), - [stack, projectName, mode, features], + () => describeInstallPlan(projectName, mode, features), + [projectName, mode, features], ) - const steps: Array = useMemo(() => { - const orderedSteps: Array = [ + const steps: Array = useMemo( + () => [ , - ] - - if (!preselectedStack) { - orderedSteps.push( - , - ) - } - - if (stack === undefined) { - return orderedSteps - } - - // A stack with no features has nothing to choose and so nothing to review: the project name - // is the whole conversation. - if (getInstallationModes(stack).length > 0) { - orderedSteps.push( - , - , - , - ) - } - - orderedSteps.push( + , + , + , , - ) - - orderedSteps.push( , - ) - - orderedSteps.push( , - ) - - orderedSteps.push( , - ) - - return orderedSteps - }, [ - finishStep, - onSelectStack, - onSelectSelectedFeatures, - onSelectSetupType, - mode, - features, - projectName, - skipFeatures, - stack, - preselectedStack, - attempt, - planSummary, - restart, - ]) + ], + [ + finishStep, + onSelectSelectedFeatures, + onSelectSetupType, + features, + projectName, + skipFeatures, + attempt, + planSummary, + restart, + ], + ) return ( = ({ preselectedStack }) => { rowGap={1} width={80} > - + {steps.map((item, index) => canShowStep(currentStep, index + 1) && item)} ) diff --git a/source/cli.tsx b/source/cli.tsx index 8b8fdfa..67e41ef 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -3,147 +3,86 @@ import process from 'node:process' import meow from 'meow' import { getInfoOutput } from './info.js' import { ReportedError, reportFailure, runNonInteractive } from './nonInteractive.js' -import { isStackName, stackNames } from './stacks/index.js' -import type { Stack } from './types/types.js' + +const flags = { + name: { + type: 'string', + }, + mode: { + type: 'string', + }, + features: { + type: 'string', + }, + nonInteractive: { + type: 'boolean', + default: false, + }, + ni: { + type: 'boolean', + default: false, + }, + info: { + type: 'boolean', + default: false, + }, +} as const const cli = meow( ` Usage $ dappbooster [options] - Stack selection (mutually exclusive) - --canton Use the Canton stack (Daml ledger, off-chain services) - --evm Use the EVM stack (Ethereum, Polygon, Base, …) [default] - --stack Explicit stack name (alternative to --canton/--evm) - - Common options + Options --name Project name (alphanumeric, underscores, dashes) - --mode Installation mode (EVM only; Canton takes neither - --mode nor --features) - --features Comma-separated features (with --mode=custom) - EVM: - demo Component demos and example pages - subgraph TheGraph subgraph integration - typedoc TypeDoc API documentation - vocs Vocs documentation site - husky Git hooks (Husky, lint-staged, commitlint) + --mode Installation mode + --features Comma-separated features (with --mode=custom): + demo Component demos and example pages + subgraph TheGraph subgraph integration + typedoc TypeDoc API documentation + vocs Vocs documentation site + husky Git hooks (Husky, lint-staged, commitlint) --non-interactive, --ni Run without prompts (auto-enabled when not a TTY) - --info Output feature metadata as JSON (filter with --stack) + --info Output feature metadata as JSON --help Show this help --version Show version Non-interactive mode - Requires --name, and --mode for a stack that has features. - Outputs JSON to stdout. Activates automatically when stdout is not a TTY. + Requires --name and --mode. Outputs JSON to stdout. + Activates automatically when stdout is not a TTY. Use --ni to force non-interactive mode in a TTY environment. AI agents: non-interactive mode activates automatically. Run --info - to discover available stacks and features (including each feature's - "requires"), then pass --canton or --evm plus --name, and --mode when - the stack lists modes. Feature dependencies are resolved automatically, - so the returned "features" list may include extras pulled in by your - selection. Output is JSON for easy parsing. + to discover available features, then pass --name and --mode flags. + Output is JSON for easy parsing. Examples - Interactive (prompts for stack and options): + Interactive: $ dappbooster - Canton stack (non-interactive): - $ dappbooster --canton --ni --name my_dapp + Full install (non-interactive): + $ dappbooster --ni --name my_dapp --mode full - EVM stack, custom install: - $ dappbooster --evm --ni --name my_dapp --mode custom --features demo,subgraph + Custom install with specific features: + $ dappbooster --ni --name my_dapp --mode custom --features demo,subgraph - Discover canton features: - $ dappbooster --info --stack canton + Get feature metadata: + $ dappbooster --info `, { importMeta: import.meta, - flags: { - stack: { - type: 'string', - }, - canton: { - type: 'boolean', - default: false, - }, - evm: { - type: 'boolean', - default: false, - }, - name: { - type: 'string', - }, - mode: { - type: 'string', - }, - features: { - type: 'string', - }, - nonInteractive: { - type: 'boolean', - default: false, - }, - ni: { - type: 'boolean', - default: false, - }, - info: { - type: 'boolean', - default: false, - }, - }, + flags, }, ) -/** Either the stack the flags name, no stack at all, or the reason the flags make no sense. */ -type StackFlagResult = { stack?: Stack; error?: string } - -function resolveStackFlag(flags: { - stack?: string - canton: boolean - evm: boolean -}): StackFlagResult { - const explicit: string[] = [] - if (flags.canton) { - explicit.push('canton') - } - if (flags.evm) { - explicit.push('evm') - } - if (flags.stack) { - explicit.push(flags.stack) - } - - const unique = Array.from(new Set(explicit)) - - if (unique.length > 1) { - return { - error: `Conflicting stack flags: ${unique.join(', ')}. Pick exactly one of --canton, --evm, or --stack.`, - } - } - - const candidate = unique[0] - if (candidate === undefined) { - return {} - } - - if (!isStackName(candidate)) { - return { error: `Invalid stack: '${candidate}'. Valid stacks: ${stackNames.join(', ')}` } - } - - return { stack: candidate } -} - -const { stack: resolvedStack, error: stackFlagError } = resolveStackFlag(cli.flags) +const unknownFlags = Object.keys(cli.flags).filter((name) => !Object.hasOwn(flags, name)) -if (stackFlagError) { - reportFailure(stackFlagError) +if (unknownFlags.length > 0) { + reportFailure(`Unknown flags: ${unknownFlags.map((name) => `--${name}`).join(', ')}`) } else if (cli.flags.info) { - console.log(getInfoOutput(resolvedStack)) + console.log(getInfoOutput()) } else if (cli.flags.nonInteractive || cli.flags.ni || !process.stdout.isTTY) { runNonInteractive({ - stack: resolvedStack, name: cli.flags.name, mode: cli.flags.mode, features: cli.flags.features, @@ -159,7 +98,7 @@ if (stackFlagError) { const { render } = await import('ink') const { default: App } = await import('./app.js') - render() + render() } run().catch(console.error) diff --git a/source/components/MainTitle.tsx b/source/components/MainTitle.tsx index 1e5f3fd..be0a0df 100644 --- a/source/components/MainTitle.tsx +++ b/source/components/MainTitle.tsx @@ -1,41 +1,15 @@ -import { Box, Text } from 'ink' import BigText from 'ink-big-text' import Gradient from 'ink-gradient' import type { FC } from 'react' -import { getStackConfig } from '../stacks/index.js' -import type { Stack } from '../types/types.js' -interface Props { - stack?: Stack -} - -const MainTitle: FC = ({ stack }) => ( - - - - - {stack && ( - - - {` ${getStackConfig(stack).label} `} - - - )} - +const MainTitle: FC = () => ( + + + ) export default MainTitle diff --git a/source/components/Multiselect/MultiSelect.tsx b/source/components/Multiselect/MultiSelect.tsx index 6aaf076..36984c9 100644 --- a/source/components/Multiselect/MultiSelect.tsx +++ b/source/components/Multiselect/MultiSelect.tsx @@ -23,15 +23,6 @@ type MultiSelectProps = { onUnselect?: (unselectedItem: Item) => void onSubmit?: (selectedItems: Item[]) => void onHighlight?: (highlightedItem: Item) => void - /** - * Post-processes a toggle, for instance to enforce feature dependencies. Receives the plain - * post-toggle selection, the item that was toggled, and the action taken. - */ - transformSelection?: ( - nextSelected: Item[], - toggledItem: Item, - action: 'select' | 'unselect', - ) => Item[] } const MultiSelect = ({ @@ -47,7 +38,6 @@ const MultiSelect = ({ onUnselect = () => {}, onSubmit = () => {}, onHighlight = () => {}, - transformSelection, }: MultiSelectProps) => { const [highlightedIndex, setHighlightedIndex] = useState(initialIndex) const [selectedItems, setSelectedItems] = useState(defaultSelected) @@ -69,31 +59,20 @@ const MultiSelect = ({ const handleSelect = useCallback( (item: Item) => { - const isCurrentlySelected = includesItems(item, selectedItems) - const action = isCurrentlySelected ? 'unselect' : 'select' - - const naiveSelection = isCurrentlySelected - ? selectedItems.filter((selectedItem) => !isSameItem(selectedItem, item)) - : [...selectedItems, item] - - const nextSelection = transformSelection - ? transformSelection(naiveSelection, item, action) - : naiveSelection - - setSelectedItems(nextSelection) - - if (isCurrentlySelected) { + if (includesItems(item, selectedItems)) { + setSelectedItems(selectedItems.filter((selectedItem) => !isSameItem(selectedItem, item))) onUnselect(item) } else { + setSelectedItems([...selectedItems, item]) onSelect(item) } }, - [selectedItems, onSelect, onUnselect, includesItems, isSameItem, transformSelection], + [selectedItems, onSelect, onUnselect, includesItems, isSameItem], ) const handleSubmit = useCallback(() => { - onSubmit(selectedItems) - }, [selectedItems, onSubmit]) + onSubmit(items.filter((item) => includesItems(item, selectedItems))) + }, [items, selectedItems, onSubmit, includesItems]) useInput( useCallback( diff --git a/source/components/steps/CantonPostInstall.tsx b/source/components/steps/CantonPostInstall.tsx deleted file mode 100644 index 4b7a433..0000000 --- a/source/components/steps/CantonPostInstall.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { Box, Text } from 'ink' -import Link from 'ink-link' -import type { FC } from 'react' -import type { PostInstallProps } from '../../types/types.js' - -const REPO_URL = 'https://github.com/BootNodeDev/canton-dappbooster' - -const CantonPostInstall: FC = ({ projectName }) => ( - - To start development on your project: - - - - Docker must be running (or start it from the dev-stack script - below). - - - - Move into the project's folder with cd {projectName} - - - - Run ./scripts/dev-stack.sh, with Docker running choose{' '} - "Stack Up". - - - - Warning: the first run pulls about 10 GB and can take a few minutes to start. - - More info: - - - - The README has more detailed - instructions about running the stack. - - - - Components documentation is available{' '} - here. - - - - Report issues with dAppBooster in the repo's{' '} - issue tracker. - - - -) - -export default CantonPostInstall diff --git a/source/components/steps/CloneRepo/CloneRepo.tsx b/source/components/steps/CloneRepo/CloneRepo.tsx index 2627c0c..b220911 100644 --- a/source/components/steps/CloneRepo/CloneRepo.tsx +++ b/source/components/steps/CloneRepo/CloneRepo.tsx @@ -1,23 +1,21 @@ import { type FC, useCallback } from 'react' import { cloneRepo } from '../../../operations/index.js' import { beginInstall } from '../../../operations/installGuard.js' -import type { Stack } from '../../../types/types.js' import { getProjectFolder } from '../../../utils/utils.js' import StepProgress from '../StepProgress.js' interface Props { - stack: Stack projectName: string onCompletion: () => void } -const CloneRepo: FC = ({ stack, projectName, onCompletion }) => { +const CloneRepo: FC = ({ projectName, onCompletion }) => { const run = useCallback( async (onProgress: (step: string) => void) => { beginInstall(getProjectFolder(projectName)) - await cloneRepo(stack, projectName, onProgress) + await cloneRepo(projectName, onProgress) }, - [stack, projectName], + [projectName], ) return ( diff --git a/source/components/steps/EvmPostInstall.tsx b/source/components/steps/EvmPostInstall.tsx deleted file mode 100644 index 545267f..0000000 --- a/source/components/steps/EvmPostInstall.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import figures from 'figures' -import { Box, Text } from 'ink' -import Link from 'ink-link' -import type { FC } from 'react' -import type { PostInstallProps } from '../../types/types.js' -import { isFeatureSelected } from '../../utils/utils.js' - -const SubgraphWarningMessage: FC = () => ( - - - - {figures.warning} - {figures.warning} WARNING: You MUST finish the - subgraph's configuration manually {figures.warning} - {figures.warning} - - - Follow these steps: - - - 1- Provide your own API key for PUBLIC_SUBGRAPHS_API_KEY in{' '} - .env.local You can get one from{' '} - The Graph Studio - - - 2- After the API key is correctly configured, run{' '} - pnpm subgraph-codegen in your console from the project's folder - - - - More configuration info in the{' '} - - dAppBooster getting-started guide - - . - - - {figures.info} Only after you have followed the previous steps you may proceed. - - -) - -const EvmPostInstall: FC = ({ projectName, features }) => ( - - {isFeatureSelected('subgraph', features) && } - - To start development on your project: - - - 1- Move into the project's folder with cd {projectName} - - - 2- Start the development server with pnpm dev - - - More info: - - - - Check out .env.local for more configurations. - - - - dAppBooster documentation is available{' '} - here. - - - - Components documentation is available{' '} - here. - - - - Report issues with dAppBooster in the repo's{' '} - issue tracker. - - - - -) - -export default EvmPostInstall diff --git a/source/components/steps/FileCleanup.tsx b/source/components/steps/FileCleanup.tsx index 5fae4a9..e916338 100644 --- a/source/components/steps/FileCleanup.tsx +++ b/source/components/steps/FileCleanup.tsx @@ -1,24 +1,21 @@ import { type FC, useCallback, useMemo } from 'react' import { cleanupFiles } from '../../operations/index.js' -import type { FeatureName, InstallationType, Stack } from '../../types/types.js' +import type { FeatureName } from '../../types/types.js' import { getProjectFolder } from '../../utils/utils.js' import StepProgress from './StepProgress.js' interface Props { - stack: Stack - mode: InstallationType features: FeatureName[] onCompletion: () => void projectName: string } -const FileCleanup: FC = ({ stack, mode, features, onCompletion, projectName }) => { +const FileCleanup: FC = ({ features, onCompletion, projectName }) => { const projectFolder = useMemo(() => getProjectFolder(projectName), [projectName]) const run = useCallback( - (onProgress: (step: string) => void) => - cleanupFiles(stack, projectFolder, mode, features, onProgress), - [stack, projectFolder, mode, features], + (onProgress: (step: string) => void) => cleanupFiles(projectFolder, features, onProgress), + [projectFolder, features], ) return ( diff --git a/source/components/steps/Install/Install.tsx b/source/components/steps/Install/Install.tsx index 67f130a..b7b8ed6 100644 --- a/source/components/steps/Install/Install.tsx +++ b/source/components/steps/Install/Install.tsx @@ -1,36 +1,27 @@ import { type FC, useCallback, useMemo } from 'react' -import { createEnvFile, createInitialCommit, installPackages } from '../../../operations/index.js' +import { createEnvFile, installPackages } from '../../../operations/index.js' import { completeInstall } from '../../../operations/installGuard.js' -import { getStackConfig } from '../../../stacks/index.js' -import type { FeatureName, InstallationType, Stack } from '../../../types/types.js' +import type { FeatureName } from '../../../types/types.js' import { getProjectFolder } from '../../../utils/utils.js' import StepProgress from '../StepProgress.js' interface Props { - stack: Stack - mode: InstallationType features: FeatureName[] projectName: string onCompletion: () => void } -const Install: FC = ({ stack, mode, features, projectName, onCompletion }) => { +const Install: FC = ({ features, projectName, onCompletion }) => { const projectFolder = useMemo(() => getProjectFolder(projectName), [projectName]) const run = useCallback( async (onProgress: (step: string) => void) => { onProgress('Creating env files') - await createEnvFile(stack, projectFolder) - await installPackages(stack, projectFolder, mode, features, onProgress) - - if (getStackConfig(stack).initialCommit) { - onProgress('Initial commit') - await createInitialCommit(projectFolder) - } - + await createEnvFile(projectFolder) + await installPackages(projectFolder, features, onProgress) completeInstall() }, - [stack, projectFolder, mode, features], + [projectFolder, features], ) return ( diff --git a/source/components/steps/InstallationMode.tsx b/source/components/steps/InstallationMode.tsx index 9a8da24..371857a 100644 --- a/source/components/steps/InstallationMode.tsx +++ b/source/components/steps/InstallationMode.tsx @@ -2,23 +2,21 @@ import figures from 'figures' import { Text } from 'ink' import SelectInput from 'ink-select-input' import { type FC, useState } from 'react' -import { getInstallationModes } from '../../stacks/index.js' -import type { InstallationSelectItem, Stack } from '../../types/types.js' +import type { InstallationSelectItem, InstallationType } from '../../types/types.js' import { MODE_LABELS } from '../../utils/utils.js' import Divider from '../Divider.js' interface Props { - stack: Stack onCompletion: () => void onSelect: (item: InstallationSelectItem) => void } -const InstallationMode: FC = ({ stack, onCompletion, onSelect }) => { - const [selected, setSelected] = useState() +const installationTypeItems: Array = Object.entries(MODE_LABELS).map( + ([value, label]) => ({ label, value: value as InstallationType }), +) - const installationTypeItems: Array = getInstallationModes(stack).map( - (value) => ({ label: MODE_LABELS[value], value }), - ) +const InstallationMode: FC = ({ onCompletion, onSelect }) => { + const [selected, setSelected] = useState() const handleSelect = (item: InstallationSelectItem) => { onSelect(item) diff --git a/source/components/steps/OptionalPackages.tsx b/source/components/steps/OptionalPackages.tsx index 4bf0fb9..65efc8c 100644 --- a/source/components/steps/OptionalPackages.tsx +++ b/source/components/steps/OptionalPackages.tsx @@ -1,53 +1,25 @@ import { Text } from 'ink' -import { type FC, useCallback, useEffect, useMemo, useState } from 'react' -import { getFeatureEntries } from '../../stacks/index.js' -import type { MultiSelectItem, Stack } from '../../types/types.js' -import { applyFeatureToggle, resolveModeFeatures } from '../../utils/utils.js' +import { type FC, useEffect, useState } from 'react' +import { defaultFeatureNames, featureEntries } from '../../config.js' +import type { MultiSelectItem } from '../../types/types.js' import MultiSelect from '../Multiselect/index.js' +const customPackages: Array = featureEntries.map(([name, definition]) => ({ + label: definition.label, + value: name, +})) + +const defaultSelected = customPackages.filter((pkg) => defaultFeatureNames.includes(pkg.value)) + interface Props { - stack: Stack onCompletion: () => void onSubmit: (selectedItems: Array) => void skip?: boolean } -const OptionalPackages: FC = ({ stack, onCompletion, onSubmit, skip = false }) => { +const OptionalPackages: FC = ({ onCompletion, onSubmit, skip = false }) => { const [submitted, setSubmitted] = useState>() - const customPackages: Array = useMemo( - () => - getFeatureEntries(stack).map(([name, definition]) => ({ - label: definition.label, - value: name, - })), - [stack], - ) - - const defaultSelected: Array = useMemo(() => { - const defaults = resolveModeFeatures(stack, 'default') - return customPackages.filter((pkg) => defaults.includes(pkg.value)) - }, [stack, customPackages]) - - const transformSelection = useCallback( - ( - nextSelected: Array, - toggledItem: MultiSelectItem, - action: 'select' | 'unselect', - ): Array => { - const resolved = applyFeatureToggle( - stack, - nextSelected.map((item) => item.value), - toggledItem.value, - action, - ) - return resolved - .map((value) => customPackages.find((pkg) => pkg.value === value)) - .filter((pkg): pkg is MultiSelectItem => pkg !== undefined) - }, - [stack, customPackages], - ) - // biome-ignore lint/correctness/useExhaustiveDependencies: Run this only once, no matter what useEffect(() => { if (skip) { @@ -87,7 +59,6 @@ const OptionalPackages: FC = ({ stack, onCompletion, onSubmit, skip = fal focus items={customPackages} onSubmit={onHandleSubmit} - transformSelection={transformSelection} /> ) diff --git a/source/components/steps/PostInstall.tsx b/source/components/steps/PostInstall.tsx index 99e4742..b566147 100644 --- a/source/components/steps/PostInstall.tsx +++ b/source/components/steps/PostInstall.tsx @@ -1,76 +1,104 @@ +import figures from 'figures' import { Box, Text } from 'ink' -import { type FC, useEffect, useState } from 'react' -import { getStackConfig } from '../../stacks/index.js' -import type { PostInstallProps, Stack } from '../../types/types.js' -import { getPostInstallMessages } from '../../utils/utils.js' +import Link from 'ink-link' +import type { FC } from 'react' +import { featureDefinitions } from '../../config.js' +import type { FeatureName } from '../../types/types.js' +import { isFeatureSelected } from '../../utils/utils.js' import Divider from '../Divider.js' -type Props = PostInstallProps & { stack: Stack } +interface Props { + projectName: string + features: FeatureName[] +} -/** The plain list of next steps, shown for a stack that brings no component of its own. */ -const DefaultMessage: FC<{ projectName: string; lines: string[] }> = ({ projectName, lines }) => ( +const SubgraphWarningMessage: FC = () => ( - Next steps: - - - - Move into the project's folder with cd {projectName} + + + {figures.warning} + {figures.warning} WARNING: You MUST finish the + subgraph's configuration manually {figures.warning} + {figures.warning} - {lines.map((line) => ( - - {line} + + Follow these steps: + + {featureDefinitions.subgraph.postInstall?.map((step, index) => ( + + {index + 1}- {step} + ))} + + More configuration info in the{' '} + + dAppBooster getting-started guide + + . + + + {figures.info} Only after you have followed the previous steps you may proceed. + ) -/** - * Closing screen. A stack can bring its own component for a richer message; without one, the same - * lines the non-interactive path reports are printed as they are. The stack's component is imported - * here rather than from the stack config, so the config stays free of terminal UI for `--info` and - * the non-interactive path. - */ -const PostInstall: FC = ({ stack, features, projectName }) => { - const { postInstallComponent: loadStackMessage } = getStackConfig(stack) - const [StackMessage, setStackMessage] = useState>() - - useEffect(() => { - if (!loadStackMessage) { - return - } - - let active = true - - loadStackMessage().then((module) => { - if (active) { - setStackMessage(() => module.default) - } - }) - - return () => { - active = false - } - }, [loadStackMessage]) - - return ( - <> - - {StackMessage && ( - - )} - {!loadStackMessage && ( - - )} - - ) -} +const PostInstall: FC = ({ projectName, features }) => ( + <> + + + {isFeatureSelected('subgraph', features) && } + + To start development on your project: + + + 1- Move into the project's folder with cd {projectName} + + + 2- Start the development server with pnpm dev + + + More info: + + + - Check out .env.local for more configurations. + + + - dAppBooster documentation is available{' '} + here. + + + - Components documentation is available{' '} + here. + + + - Report issues with dAppBooster in the repo's{' '} + issue tracker. + + + + + +) export default PostInstall diff --git a/source/components/steps/StackSelection.tsx b/source/components/steps/StackSelection.tsx deleted file mode 100644 index 27cc98c..0000000 --- a/source/components/steps/StackSelection.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import figures from 'figures' -import { Text } from 'ink' -import SelectInput from 'ink-select-input' -import { type FC, useState } from 'react' -import { stackDefinitions, stackNames } from '../../stacks/index.js' -import type { Stack } from '../../types/types.js' -import Divider from '../Divider.js' - -interface Props { - onCompletion: () => void - onSelect: (stack: Stack) => void -} - -const stackItems = stackNames.map((name) => ({ - label: `${stackDefinitions[name].label} — ${stackDefinitions[name].description}`, - value: name, -})) - -const StackSelection: FC = ({ onCompletion, onSelect }) => { - const [selectedStack, setSelectedStack] = useState() - - const handleSelect = (item: { value: Stack }) => { - onSelect(item.value) - setSelectedStack(item.value) - onCompletion() - } - - return ( - <> - - {selectedStack ? ( - - Stack:{' '} - - {stackDefinitions[selectedStack].label} - - - ) : ( - <> - Which stack do you want to scaffold? - ( - {isSelected ? `${figures.pointer} ` : ' '} - )} - itemComponent={({ label, isSelected }) => ( - - {label} - - )} - items={stackItems} - onSelect={handleSelect} - /> - - )} - - ) -} - -export default StackSelection diff --git a/source/config.ts b/source/config.ts new file mode 100644 index 0000000..5325be9 --- /dev/null +++ b/source/config.ts @@ -0,0 +1,104 @@ +import process from 'node:process' +import type { FeatureDefinition, FeatureName } from './types/types.js' + +export const featureDefinitions: Record = { + demo: { + description: 'Component demos and example pages', + label: 'Component Demos', + packages: [], + default: true, + paths: ['src/components/pageComponents/home'], + }, + subgraph: { + description: 'TheGraph subgraph integration', + label: 'Subgraph support', + packages: [ + '@bootnodedev/db-subgraph', + 'graphql', + 'graphql-request', + '@graphql-codegen/cli', + '@graphql-typed-document-node/core', + ], + default: true, + paths: ['src/subgraphs'], + scripts: ['subgraph-codegen'], + postInstall: [ + 'Provide your own API key for PUBLIC_SUBGRAPHS_API_KEY in .env.local. You can get one from The Graph Studio: https://thegraph.com/studio/apikeys', + 'Once the API key is set, run pnpm subgraph-codegen from the project folder', + ], + }, + typedoc: { + description: 'TypeDoc API documentation generation', + label: 'Typedoc documentation support', + packages: [ + 'typedoc', + 'typedoc-github-theme', + 'typedoc-plugin-inline-sources', + 'typedoc-plugin-missing-exports', + 'typedoc-plugin-rename-defaults', + ], + default: true, + paths: ['typedoc.json'], + scripts: ['typedoc:build'], + }, + vocs: { + description: 'Vocs documentation site', + label: 'Vocs documentation support', + packages: ['vocs'], + default: true, + paths: ['vocs.config.ts', 'docs'], + scripts: ['docs:build', 'docs:dev', 'docs:preview'], + }, + husky: { + description: 'Git hooks with Husky, lint-staged, and commitlint', + label: 'Husky Git hooks support', + packages: ['husky', 'lint-staged', '@commitlint/cli', '@commitlint/config-conventional'], + default: true, + paths: ['.husky', '.lintstagedrc.mjs', 'commitlint.config.js'], + scripts: ['prepare', 'commitlint', 'commitlint:check', 'commitlint:ci'], + }, +} + +export const featureEntries = Object.entries(featureDefinitions) as Array< + [FeatureName, FeatureDefinition] +> + +export const featureNames = featureEntries.map(([name]) => name) + +export const defaultFeatureNames = featureEntries + .filter(([, definition]) => definition.default) + .map(([name]) => name) + +export function isFeatureNameValid(name: string): name is FeatureName { + return Object.hasOwn(featureDefinitions, name) +} + +export const packageManager = 'pnpm' + +/** Paths the template keeps for itself. Every scaffold drops them, whatever the user picked. */ +export const prepare = { + label: 'Repository metadata', + paths: ['.claude', 'AGENTS.md', 'CLAUDE.md', 'architecture.md', '.github'], +} + +/** + * The template's staging directory, holding the replacement files a deselected feature restores. + * Removed once cleanup is finished with it. + */ +export const staging = { + label: 'Install script', + paths: ['.install-files'], +} + +/** + * The repository to clone. Without a `ref` the newest tag is cloned. `DAPPBOOSTER_REPO_URL` and + * `DAPPBOOSTER_REF` override both, for forks and pre-release testing. + */ +export function getRepo(): { url: string; ref?: string } { + const { DAPPBOOSTER_REPO_URL, DAPPBOOSTER_REF } = process.env + + return { + url: DAPPBOOSTER_REPO_URL || 'https://github.com/BootNodeDev/dAppBooster.git', + ref: DAPPBOOSTER_REF || undefined, + } +} diff --git a/source/info.ts b/source/info.ts index 8948a9d..919cf08 100644 --- a/source/info.ts +++ b/source/info.ts @@ -1,80 +1,24 @@ -import { - getFeatureEntries, - getInstallationModes, - stackDefinitions, - stackNames, -} from './stacks/index.js' -import type { InstallationType, Stack } from './types/types.js' - -type FeatureInfo = { - description: string - default: boolean - postInstall?: string[] - requires?: string[] -} - -type StackInfo = { - label: string - description: string - packageManager: string - modes: InstallationType[] - features: Record -} - -function buildStackInfo(stack: Stack): StackInfo { - const config = stackDefinitions[stack] - - return { - label: config.label, - description: config.description, - packageManager: config.packageManager, - modes: getInstallationModes(stack), - features: Object.fromEntries( - getFeatureEntries(stack).map(([name, definition]) => [ - name, - { - description: definition.description, - default: definition.default, - ...(definition.postInstall ? { postInstall: definition.postInstall } : {}), - ...(definition.requires ? { requires: definition.requires } : {}), - }, - ]), - ), - } -} - -export class InvalidStackFilterError extends Error { - constructor(filter: string) { - super(`Unknown stack '${filter}'. Valid stacks: ${stackNames.join(', ')}`) - this.name = 'InvalidStackFilterError' - } -} - -export function getInfoOutput(stackFilter?: string): string { - if (stackFilter !== undefined && !(stackNames as string[]).includes(stackFilter)) { - throw new InvalidStackFilterError(stackFilter) - } - - const stacks: Record = {} - - for (const name of stackNames) { - if (stackFilter !== undefined && stackFilter !== name) { - continue - } - stacks[name] = buildStackInfo(name) - } +import { featureEntries } from './config.js' + +export function getInfoOutput(): string { + const features = Object.fromEntries( + featureEntries.map(([name, definition]) => [ + name, + { + description: definition.description, + default: definition.default, + postInstall: definition.postInstall, + }, + ]), + ) return JSON.stringify( { - stacks, + features, modes: { full: 'Install all features', - default: - 'Install the recommended set. Only for stacks that list it in their own "modes" — every other stack rejects it', custom: 'Choose features individually', }, - notes: - 'A stack whose "modes" list is empty has no optional features. Send neither --mode nor --features', }, null, 2, diff --git a/source/nonInteractive.ts b/source/nonInteractive.ts index 922a10c..0967f65 100644 --- a/source/nonInteractive.ts +++ b/source/nonInteractive.ts @@ -1,21 +1,8 @@ import process from 'node:process' -import { - cleanupFiles, - cloneRepo, - createEnvFile, - createInitialCommit, - installPackages, -} from './operations/index.js' +import { featureNames, isFeatureNameValid } from './config.js' +import { cleanupFiles, cloneRepo, createEnvFile, installPackages } from './operations/index.js' import { beginInstall, completeInstall } from './operations/installGuard.js' -import { - getFeatureNames, - getInstallationModes, - getStackConfig, - isFeatureNameValid, - isStackName, - stackNames, -} from './stacks/index.js' -import type { FeatureName, InstallationType, Stack } from './types/types.js' +import type { FeatureName, InstallationType } from './types/types.js' import { getPostInstallMessages, getProjectFolder, @@ -26,7 +13,6 @@ import { type SuccessResult = { success: true - stack: Stack projectName: string mode: InstallationType features: FeatureName[] @@ -73,40 +59,21 @@ function parseFeatures(featuresFlag: string | undefined): string[] { }) } -/** The mode and features the flags ask for, once the stack is known to be valid. */ -function resolveModeAndFeatures( - stack: Stack, - flags: { mode?: string; features?: string }, -): { mode: InstallationType; features: FeatureName[] } { - const modes = getInstallationModes(stack) - - // A stack with no features has nothing to choose, so both flags are a mistake worth naming. - if (modes.length === 0) { - for (const flag of ['mode', 'features'] as const) { - if (flags[flag]) { - fail(`The ${stack} stack has no optional features, so --${flag} is not accepted`) - } - } - - return { mode: 'full', features: [] } - } - +/** The mode and features the flags ask for. */ +function resolveModeAndFeatures(flags: { mode?: string; features?: string }): { + mode: InstallationType + features: FeatureName[] +} { if (!flags.mode) { fail('Missing required flag: --mode') } - if (flags.mode !== 'full' && flags.mode !== 'default' && flags.mode !== 'custom') { - fail("Invalid mode: must be 'full', 'default', or 'custom'") - } - - if (!modes.includes(flags.mode)) { - fail( - `Invalid mode: '${flags.mode}' is not available for the ${stack} stack. Valid modes: ${modes.join(', ')}`, - ) + if (flags.mode !== 'full' && flags.mode !== 'custom') { + fail("Invalid mode: must be 'full' or 'custom'") } - if (flags.mode !== 'custom') { - return { mode: flags.mode, features: resolveModeFeatures(stack, flags.mode) } + if (flags.mode === 'full') { + return { mode: 'full', features: resolveModeFeatures('full') } } if (!flags.features) { @@ -123,7 +90,7 @@ function resolveModeAndFeatures( const invalidFeatures: string[] = [] for (const name of requested) { - if (isFeatureNameValid(stack, name)) { + if (isFeatureNameValid(name)) { features.push(name) } else { invalidFeatures.push(name) @@ -131,29 +98,18 @@ function resolveModeAndFeatures( } if (invalidFeatures.length > 0) { - const validNames = getFeatureNames(stack).join(', ') - fail( - `Unknown features for stack '${stack}': ${invalidFeatures.join(', ')}. Valid features: ${validNames}`, - ) + const validNames = featureNames.join(', ') + fail(`Unknown features: ${invalidFeatures.join(', ')}. Valid features: ${validNames}`) } - return { mode: 'custom', features: resolveModeFeatures(stack, 'custom', features) } + return { mode: 'custom', features: resolveModeFeatures('custom', features) } } -function validate(flags: { stack?: string; name?: string; mode?: string; features?: string }): { - stack: Stack +function validate(flags: { name?: string; mode?: string; features?: string }): { name: string mode: InstallationType features: FeatureName[] } { - const stackFlag = flags.stack ?? 'evm' - - if (!isStackName(stackFlag)) { - fail(`Invalid stack: '${stackFlag}'. Valid stacks: ${stackNames.join(', ')}`) - } - - const stack = stackFlag - if (!flags.name) { fail('Missing required flag: --name') } @@ -164,47 +120,41 @@ function validate(flags: { stack?: string; name?: string; mode?: string; feature ) } - const { mode, features } = resolveModeAndFeatures(stack, flags) + const { mode, features } = resolveModeAndFeatures(flags) if (projectDirectoryExists(flags.name)) { fail(`Project directory '${flags.name}' already exists`) } - return { stack, name: flags.name, mode, features } + return { name: flags.name, mode, features } } export async function runNonInteractive(flags: { - stack?: string name?: string mode?: string features?: string }): Promise { - const { stack, name, mode, features } = validate(flags) + const { name, mode, features } = validate(flags) const projectFolder = getProjectFolder(name) try { beginInstall(projectFolder) - await cloneRepo(stack, name) - await cleanupFiles(stack, projectFolder, mode, features) - await createEnvFile(stack, projectFolder) - await installPackages(stack, projectFolder, mode, features) - - if (getStackConfig(stack).initialCommit) { - await createInitialCommit(projectFolder) - } + await cloneRepo(name) + await cleanupFiles(projectFolder, features) + await createEnvFile(projectFolder) + await installPackages(projectFolder, features) completeInstall() const result: SuccessResult = { success: true, - stack, projectName: name, mode, features, path: projectFolder, - postInstall: getPostInstallMessages(stack, features), + postInstall: getPostInstallMessages(features), } console.log(JSON.stringify(result, null, 2)) diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index cb03018..ab2d949 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -1,77 +1,48 @@ -import { readFileSync, statSync, writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { copyFile, mkdir, rm } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import { getFeatureEntries, getStackConfig } from '../stacks/index.js' -import type { FeatureName, InstallationType, Stack } from '../types/types.js' +import { featureEntries, prepare, staging } from '../config.js' +import type { FeatureName } from '../types/types.js' import { isFeatureSelected } from '../utils/utils.js' const HOME_FOLDER = 'src/components/pageComponents/home' -type DependencyGroup = Record | undefined - type PackageJson = { scripts?: Record - dependencies?: DependencyGroup - devDependencies?: DependencyGroup -} - -/** - * What the deleted paths leave for the package.json pass to apply. - * - * @property removedDirs - Directories that were deleted. Scripts that run one of them go too. - */ -type CleanupPlan = { - scripts: string[] - devDependencies: string[] - removedDirs: string[] } -function isDirectory(path: string): boolean { - return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false -} - -/** Removes the given paths and reports which of them were directories. */ -async function removePaths(projectFolder: string, relativePaths: string[]): Promise { - const directories = relativePaths.filter((relativePath) => - isDirectory(resolve(projectFolder, relativePath)), - ) - +async function removePaths(projectFolder: string, relativePaths: string[]): Promise { await Promise.all( relativePaths.map((relativePath) => rm(resolve(projectFolder, relativePath), { recursive: true, force: true }), ), ) - - return directories } /** - * Deletes the files of every feature the user left out and adds what the package.json pass still - * has to remove to `plan`. `full` mode keeps everything. + * Deletes the files of every feature the user left out and returns the package.json scripts they + * leave behind. */ async function removeDeselectedFeatures( - stack: Stack, projectFolder: string, - mode: InstallationType, features: FeatureName[], - plan: CleanupPlan, onProgress?: (step: string) => void, -): Promise { - if (mode === 'full') { - return - } +): Promise { + const scripts: string[] = [] - for (const [name, definition] of getFeatureEntries(stack)) { - const { paths = [], scripts = [] } = definition + for (const [name, definition] of featureEntries) { + const { paths = [], scripts: featureScripts = [] } = definition - if (isFeatureSelected(name, features) || (paths.length === 0 && scripts.length === 0)) { + if (isFeatureSelected(name, features) || (paths.length === 0 && featureScripts.length === 0)) { continue } onProgress?.(definition.label) - plan.removedDirs.push(...(await removePaths(projectFolder, paths))) - plan.scripts.push(...scripts) + await removePaths(projectFolder, paths) + scripts.push(...featureScripts) } + + return scripts } async function restoreFile(projectFolder: string, from: string, to: string): Promise { @@ -107,53 +78,12 @@ async function restoreStagedHomePage( } } -function removeKeys(block: Record | undefined, keys: string[]): boolean { - if (!block) { - return false - } - - let changed = false - - for (const key of keys) { - if (key in block) { - delete block[key] - changed = true - } - } - - return changed -} - /** - * Names the scripts that run a removed directory, so cleanup tracks the removal even when the - * template renames its scripts. + * Removes the given scripts from the project's package.json in a single pass, writing only when + * something changed. Feature dependencies are left to the package manager, which runs next. */ -function scriptsRunningRemovedDirs( - scripts: Record | undefined, - removedDirs: string[], -): string[] { - if (!scripts) { - return [] - } - - return Object.entries(scripts) - .filter(([, command]) => - command - ?.split(/\s+/) - .some((token) => removedDirs.some((dir) => token === dir || token.startsWith(`${dir}/`))), - ) - .map(([name]) => name) -} - -/** - * Applies the plan to the project's package.json in a single pass, writing only when something - * changed. Feature dependencies are left to the package manager, which runs next; the prepare - * step's dependencies go here instead, because the package manager must never see them. - */ -function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { - const { scripts, devDependencies, removedDirs } = plan - - if (scripts.length === 0 && devDependencies.length === 0 && removedDirs.length === 0) { +function patchPackageJson(projectFolder: string, scripts: string[]): void { + if (scripts.length === 0) { return } @@ -167,16 +97,18 @@ function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { return } - const scriptsRemoved = removeKeys(packageJson.scripts, [ - ...scripts, - ...scriptsRunningRemovedDirs(packageJson.scripts, removedDirs), - ]) + const current = packageJson.scripts ?? {} + const present = scripts.filter((name) => name in current) - const dependenciesRemoved = removeKeys(packageJson.devDependencies, devDependencies) + if (present.length === 0) { + return + } - if (scriptsRemoved || dependenciesRemoved) { - writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) + for (const name of present) { + delete current[name] } + + writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) } /** @@ -185,36 +117,17 @@ function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { * the pruned manifest once and the lockfile it writes needs no repair. */ export async function cleanupFiles( - stack: Stack, projectFolder: string, - mode: InstallationType, - features: FeatureName[] = [], + features: FeatureName[], onProgress?: (step: string) => void, ): Promise { - const { prepare, staging } = getStackConfig(stack) - - const plan: CleanupPlan = { - scripts: [...(prepare?.scripts ?? [])], - devDependencies: [...(prepare?.devDependencies ?? [])], - removedDirs: [], - } - - if (prepare) { - onProgress?.(prepare.label) - plan.removedDirs.push(...(await removePaths(projectFolder, prepare.paths))) - } - - await removeDeselectedFeatures(stack, projectFolder, mode, features, plan, onProgress) + onProgress?.(prepare.label) + await removePaths(projectFolder, prepare.paths) - // The staged replacements only exist in stacks that ship a staging directory. - if (staging && mode !== 'full') { - await restoreStagedHomePage(projectFolder, features) - } + const scripts = await removeDeselectedFeatures(projectFolder, features, onProgress) + await restoreStagedHomePage(projectFolder, features) + patchPackageJson(projectFolder, scripts) - patchPackageJson(projectFolder, plan) - - if (staging) { - onProgress?.(staging.label) - await removePaths(projectFolder, staging.paths) - } + onProgress?.(staging.label) + await removePaths(projectFolder, staging.paths) } diff --git a/source/operations/cloneRepo.ts b/source/operations/cloneRepo.ts index 4cb0db8..2af3c66 100644 --- a/source/operations/cloneRepo.ts +++ b/source/operations/cloneRepo.ts @@ -1,52 +1,38 @@ import { rm } from 'node:fs/promises' import { resolve } from 'node:path' -import process from 'node:process' -import { getStackConfig } from '../stacks/index.js' -import type { Stack } from '../types/types.js' -import { getProjectFolder, meetsNodeVersion } from '../utils/utils.js' +import { getRepo, packageManager } from '../config.js' +import { getProjectFolder } from '../utils/utils.js' import { exec, execFile } from './exec.js' /** - * Clones the stack's repository into `projectName`, then hands the user a fresh repository: the - * template's `.git` goes and `git init` runs in its place. A stack is cloned at its newest tag - * unless `ref` names one, which today only `DAPPBOOSTER__REF` does. Picking the newest tag - * is the one place a shell is used, because it needs `$()` command substitution. + * Clones dAppBooster into `projectName`, then hands the user a fresh repository: the template's + * `.git` goes and `git init` runs in its place. The newest tag is cloned unless `DAPPBOOSTER_REF` + * names a ref. Picking the newest tag is the one place a shell is used, because it needs `$()` + * command substitution. */ export async function cloneRepo( - stack: Stack, projectName: string, onProgress?: (step: string) => void, ): Promise { - const config = getStackConfig(stack) + const { url, ref } = getRepo() const projectFolder = getProjectFolder(projectName) - if (config.minNodeVersion && !meetsNodeVersion(config.minNodeVersion)) { - throw new Error( - `The ${config.label} stack needs Node ${config.minNodeVersion} or later. You are running ${process.versions.node}.`, - ) - } - // The installer runs this a few steps later. Checking now saves a clone that cannot be used. try { - await execFile(config.packageManager, ['--version']) + await execFile(packageManager, ['--version']) } catch { - throw new Error( - `${config.packageManager} was not found. Install it, then run the installer again.`, - ) + throw new Error(`${packageManager} was not found. Install it, then run the installer again.`) } - if (config.ref) { - // Fetching the ref by name keeps the clone shallow. It works for a tag or a branch. - onProgress?.(`Cloning ${config.label} (${config.ref}) in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) + onProgress?.(`Cloning dAppBooster${ref ? ` (${ref})` : ''} in ${projectName}`) + await execFile('git', ['clone', '--depth', '1', '--no-checkout', url, projectName]) - onProgress?.(`Checking out ${config.ref}`) - await execFile('git', ['fetch', '--depth', '1', 'origin', config.ref], { cwd: projectFolder }) + if (ref) { + // Fetching the ref by name keeps the clone shallow. It works for a tag or a branch. + onProgress?.(`Checking out ${ref}`) + await execFile('git', ['fetch', '--depth', '1', 'origin', ref], { cwd: projectFolder }) await execFile('git', ['checkout', 'FETCH_HEAD'], { cwd: projectFolder }) } else { - onProgress?.(`Cloning ${config.label} in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) - onProgress?.('Fetching tags') await execFile('git', ['fetch', '--tags'], { cwd: projectFolder }) diff --git a/source/operations/createEnvFile.ts b/source/operations/createEnvFile.ts index bdff024..9af385b 100644 --- a/source/operations/createEnvFile.ts +++ b/source/operations/createEnvFile.ts @@ -1,10 +1,6 @@ import { copyFile } from 'node:fs/promises' import { join } from 'node:path' -import { getStackConfig } from '../stacks/index.js' -import type { Stack } from '../types/types.js' -export async function createEnvFile(stack: Stack, projectFolder: string): Promise { - for (const file of getStackConfig(stack).envFiles) { - await copyFile(join(projectFolder, file.from), join(projectFolder, file.to)) - } +export async function createEnvFile(projectFolder: string): Promise { + await copyFile(join(projectFolder, '.env.example'), join(projectFolder, '.env.local')) } diff --git a/source/operations/createInitialCommit.ts b/source/operations/createInitialCommit.ts deleted file mode 100644 index be9fd8b..0000000 --- a/source/operations/createInitialCommit.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { execFile } from './exec.js' - -/** - * Commits the finished scaffold as the project's baseline, so the user can see what they changed - * afterwards. The commit uses the user's own git identity. `--no-verify` keeps the project's own - * hooks from linting a tree they have not touched yet; their later commits run the hooks normally. - */ -export async function createInitialCommit(projectFolder: string): Promise { - await execFile('git', ['add', '.'], { cwd: projectFolder }) - await execFile( - 'git', - ['-c', 'commit.gpgsign=false', 'commit', '--no-verify', '-m', 'chore: initial commit'], - { cwd: projectFolder }, - ) -} diff --git a/source/operations/index.ts b/source/operations/index.ts index 90d9d80..411286f 100644 --- a/source/operations/index.ts +++ b/source/operations/index.ts @@ -1,5 +1,4 @@ export { cleanupFiles } from './cleanupFiles.js' export { cloneRepo } from './cloneRepo.js' export { createEnvFile } from './createEnvFile.js' -export { createInitialCommit } from './createInitialCommit.js' export { installPackages } from './installPackages.js' diff --git a/source/operations/installPackages.ts b/source/operations/installPackages.ts index 2b2bbfa..f7bdbbc 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -1,33 +1,18 @@ -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { getStackConfig } from '../stacks/index.js' -import type { FeatureName, InstallationType, Stack } from '../types/types.js' +import { packageManager } from '../config.js' +import type { FeatureName } from '../types/types.js' import { getPackagesToRemove } from '../utils/utils.js' import { execFile } from './exec.js' -/** Whether the scaffolded project defines the named script. */ -function hasScript(projectFolder: string, name: string): boolean { - try { - const packageJson = JSON.parse(readFileSync(resolve(projectFolder, 'package.json'), 'utf8')) - return packageJson.scripts?.[name] !== undefined - } catch { - return false - } -} - /** * Installs the project's dependencies, minus the packages of every feature the user left out. * Removing through the package manager keeps package.json and the lockfile in step. */ export async function installPackages( - stack: Stack, projectFolder: string, - mode: InstallationType, - features: FeatureName[] = [], + features: FeatureName[], onProgress?: (step: string) => void, ): Promise { - const { packageManager } = getStackConfig(stack) - const packagesToRemove = mode === 'full' ? [] : getPackagesToRemove(stack, features) + const packagesToRemove = getPackagesToRemove(features) onProgress?.('Installing packages') @@ -38,8 +23,6 @@ export async function installPackages( await execFile(packageManager, ['remove', ...packagesToRemove], { cwd: projectFolder }) - if (hasScript(projectFolder, 'postinstall')) { - onProgress?.('Executing post-install scripts') - await execFile(packageManager, ['run', 'postinstall'], { cwd: projectFolder }) - } + onProgress?.('Executing post-install scripts') + await execFile(packageManager, ['run', '--if-present', 'postinstall'], { cwd: projectFolder }) } diff --git a/source/stacks/canton.ts b/source/stacks/canton.ts deleted file mode 100644 index 8ce9598..0000000 --- a/source/stacks/canton.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { StackConfig } from '../types/types.js' - -/** - * Scaffolding Canton is deletion. The template's three libraries are on npm as - * `@bootnodedev/canton-connect`, `@bootnodedev/canton-dappbooster` and `@bootnodedev/canton-theme`; - * pnpm links the local folder only while its own version satisfies the declared range, so deleting - * the folders makes the same package.json resolve from the registry. No manifest rewrite, no - * workspace file edit. - */ -export const canton = { - label: 'Canton', - description: 'dAppBooster for Canton (Daml ledger, off-chain services)', - repoUrl: 'https://github.com/BootNodeDev/canton-dappbooster.git', - packageManager: 'pnpm', - minNodeVersion: '24.15.0', - initialCommit: true, - prepare: { - label: 'Removing the template libraries and tooling', - paths: [ - '.claude', - '.github', - 'canton-connect', - 'canton-dappbooster', - 'canton-theme', - 'kit', - 'AGENTS.md', - 'CLAUDE.md', - 'architecture.md', - 'renovate.json', - 'vercel.json', - 'dapp/frontend/AGENTS.md', - 'dapp/frontend/CLAUDE.md', - 'dapp/frontend/architecture.md', - 'dapp/frontend/PROVENANCE.md', - 'dapp/daml/PROVENANCE.md', - 'pnpm-lock.yaml', - ], - // Scripts that run kit/ go when the folder goes. These two only name the libraries in a - // filter argument, so nothing links them to a deleted path. - scripts: ['release', 'release:dry'], - devDependencies: ['typedoc', 'postcss', '@mermaid-js/mermaid-cli'], - }, - postInstallComponent: () => import('../components/steps/CantonPostInstall.js'), - postInstall: [ - 'Run every command below from the folder reported in "path"', - 'Docker must be running. On macOS "./scripts/dev-stack.sh docker-up" starts Docker Desktop and waits for it', - 'The DAML SDK must be installed ("dpm" on PATH)', - 'Start the stack with "./scripts/dev-stack.sh up". The script with no argument opens an arrow-key menu and fails without a terminal', - '"up" prints each step as it runs and fails on its own. The first run pulls about 10 GB, so do not kill it for being slow', - 'Check it with "./scripts/dev-stack.sh status" and stop it with "./scripts/dev-stack.sh down"', - 'The dApp needs a CIP-0103 browser wallet, which runs outside the project: https://github.com/BootNodeDev/carpincho-wallet', - 'Read README.md for how to connect it', - ], - envFiles: [{ from: '.env.example', to: '.env' }], - features: {}, -} satisfies StackConfig diff --git a/source/stacks/evm.ts b/source/stacks/evm.ts deleted file mode 100644 index 303ebd7..0000000 --- a/source/stacks/evm.ts +++ /dev/null @@ -1,75 +0,0 @@ -import type { FeatureDefinition, FeatureName, StackConfig } from '../types/types.js' - -export const evm = { - label: 'EVM', - description: 'dAppBooster for EVM chains (Ethereum, Polygon, Base, …)', - repoUrl: 'https://github.com/BootNodeDev/dAppBooster.git', - packageManager: 'pnpm', - prepare: { - label: 'Repository metadata', - paths: ['.claude', 'AGENTS.md', 'CLAUDE.md', 'architecture.md', '.github'], - }, - staging: { - label: 'Install script', - paths: ['.install-files'], - }, - postInstallComponent: () => import('../components/steps/EvmPostInstall.js'), - envFiles: [{ from: '.env.example', to: '.env.local' }], - features: { - demo: { - description: 'Component demos and example pages', - label: 'Component Demos', - packages: [], - default: true, - paths: ['src/components/pageComponents/home'], - }, - subgraph: { - description: 'TheGraph subgraph integration', - label: 'Subgraph support', - packages: [ - '@bootnodedev/db-subgraph', - 'graphql', - 'graphql-request', - '@graphql-codegen/cli', - '@graphql-typed-document-node/core', - ], - default: true, - paths: ['src/subgraphs'], - scripts: ['subgraph-codegen'], - postInstall: [ - 'Provide your own API key for PUBLIC_SUBGRAPHS_API_KEY in .env.local', - 'Run pnpm subgraph-codegen from the project folder', - ], - }, - typedoc: { - description: 'TypeDoc API documentation generation', - label: 'Typedoc documentation support', - packages: [ - 'typedoc', - 'typedoc-github-theme', - 'typedoc-plugin-inline-sources', - 'typedoc-plugin-missing-exports', - 'typedoc-plugin-rename-defaults', - ], - default: true, - paths: ['typedoc.json'], - scripts: ['typedoc:build'], - }, - vocs: { - description: 'Vocs documentation site', - label: 'Vocs documentation support', - packages: ['vocs'], - default: true, - paths: ['vocs.config.ts', 'docs'], - scripts: ['docs:build', 'docs:dev', 'docs:preview'], - }, - husky: { - description: 'Git hooks with Husky, lint-staged, and commitlint', - label: 'Husky Git hooks support', - packages: ['husky', 'lint-staged', '@commitlint/cli', '@commitlint/config-conventional'], - default: true, - paths: ['.husky', '.lintstagedrc.mjs', 'commitlint.config.js'], - scripts: ['prepare', 'commitlint', 'commitlint:check', 'commitlint:ci'], - }, - }, -} satisfies StackConfig & { features: Record } diff --git a/source/stacks/index.ts b/source/stacks/index.ts deleted file mode 100644 index 0706897..0000000 --- a/source/stacks/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import process from 'node:process' -import type { - FeatureDefinition, - FeatureName, - InstallationType, - Stack, - StackConfig, -} from '../types/types.js' -import { canton } from './canton.js' -import { evm } from './evm.js' - -export const stackDefinitions = { evm, canton } satisfies Record - -export const stackNames = Object.keys(stackDefinitions) as Stack[] - -function envOverride(stack: Stack, suffix: 'REPO_URL' | 'REF'): string | undefined { - const key = `DAPPBOOSTER_${stack.toUpperCase()}_${suffix}` - const value = process.env[key] - return value && value.length > 0 ? value : undefined -} - -export function getStackConfig(stack: Stack): StackConfig { - const base: StackConfig = stackDefinitions[stack] - const repoUrl = envOverride(stack, 'REPO_URL') ?? base.repoUrl - const ref = envOverride(stack, 'REF') ?? base.ref - return { ...base, repoUrl, ref } -} - -/** A stack's feature map as entries. The keys are feature names by construction. */ -export function getFeatureEntries(stack: Stack): Array<[FeatureName, FeatureDefinition]> { - return Object.entries(stackDefinitions[stack].features) as Array<[FeatureName, FeatureDefinition]> -} - -export function getFeatureNames(stack: Stack): FeatureName[] { - return getFeatureEntries(stack).map(([name]) => name) -} - -export function isFeatureNameValid(stack: Stack, name: string): name is FeatureName { - return name in stackDefinitions[stack].features -} - -export function isStackName(name: string): name is Stack { - return (stackNames as string[]).includes(name) -} - -export function getDefaultFeatureNames(stack: Stack): FeatureName[] { - return getFeatureEntries(stack) - .filter(([, definition]) => definition.default) - .map(([name]) => name) -} - -/** - * Installation modes a stack offers. A stack with no features offers none: there is nothing to - * choose, so the wizard skips both questions and the CLI rejects `--mode` and `--features`. - * `default` (keep only the `default: true` features) needs at least one opt-out feature, or it - * would be identical to `full`. - */ -export function getInstallationModes(stack: Stack): InstallationType[] { - const featureNames = getFeatureNames(stack) - - if (featureNames.length === 0) { - return [] - } - - const hasOptOutFeature = getDefaultFeatureNames(stack).length < featureNames.length - return hasOptOutFeature ? ['default', 'full', 'custom'] : ['full', 'custom'] -} diff --git a/source/types/types.ts b/source/types/types.ts index 2d290f6..feede79 100644 --- a/source/types/types.ts +++ b/source/types/types.ts @@ -1,16 +1,7 @@ -import type { FC } from 'react' - -export type Stack = 'evm' | 'canton' - -export type PackageManager = 'pnpm' - -/** - * Every feature name the stacks define. Only the EVM stack has features; `stacks/evm.ts` fails to - * compile if its feature map and this list disagree. - */ +/** Every feature name the config defines. `config.ts` fails to compile if they disagree. */ export type FeatureName = 'demo' | 'subgraph' | 'typedoc' | 'vocs' | 'husky' -export type InstallationType = 'full' | 'default' | 'custom' +export type InstallationType = 'full' | 'custom' export type MultiSelectItem = { label: string; value: FeatureName } @@ -20,15 +11,12 @@ export type InstallationSelectItem = { label: string; value: InstallationType } export type PlanSummaryItem = { label: string; value: string } /** - * A single optional feature of a stack. + * A single optional feature of the template. * * @property packages - Dependencies the package manager removes (`pnpm remove`) when the feature * is deselected, so package.json and the lockfile stay in step. * @property paths - Relative files and directories deleted when the feature is deselected. - * Removed directories also drive script stripping in cleanupFiles. * @property scripts - package.json script names deleted when the feature is deselected. - * @property requires - Other features this one depends on. Selecting it pulls these in; - * deselecting one of these cascades this feature out. Resolved transitively (see utils.ts). */ export type FeatureDefinition = { description: string @@ -38,63 +26,4 @@ export type FeatureDefinition = { postInstall?: string[] paths?: string[] scripts?: string[] - requires?: FeatureName[] -} - -export type EnvFile = { - from: string - to: string -} - -/** A labelled group of paths cleanup removes whatever the user picked. */ -export type CleanupGroup = { - label: string - paths: string[] -} - -/** - * What every scaffold of a stack drops, whatever the user picked: paths the template keeps for - * itself, and the package.json keys that point at them. Applied before the package manager runs, - * so it resolves the pruned manifest once and the lockfile it writes needs no repair. - */ -export type PrepareStep = CleanupGroup & { - scripts?: string[] - devDependencies?: string[] -} - -export type PostInstallProps = { - projectName: string - features: FeatureName[] -} - -/** - * A stack the installer can scaffold. - * - * @property ref - Tag or branch to clone. Left out, the stack is cloned at its newest tag. Today - * only `DAPPBOOSTER__REF` sets it, for forks and pre-release testing. - * @property minNodeVersion - Node the scaffolded project needs. Checked before cloning, so a too - * old Node fails with a plain message instead of a confusing install error later. - * @property postInstall - Guidance shown for every scaffold of this stack, in any mode. - * @property postInstallComponent - Richer post-install screen for the wizard, as a function that - * imports it. It stays a function so the terminal UI is only loaded by the wizard: `--info` and - * the non-interactive path read this same config and must not pull Ink in. Stacks without one get - * their `postInstall` lines printed. - * @property staging - The template's staging directory, holding the replacement files a - * deselected feature restores. Removed once cleanup is finished with it. - * @property initialCommit - Whether to commit the finished scaffold as the project's baseline. - */ -export type StackConfig = { - label: string - description: string - repoUrl: string - ref?: string - packageManager: PackageManager - minNodeVersion?: string - prepare?: PrepareStep - postInstall?: string[] - postInstallComponent?: () => Promise<{ default: FC }> - staging?: CleanupGroup - initialCommit?: boolean - envFiles: EnvFile[] - features: Record } diff --git a/source/utils/utils.ts b/source/utils/utils.ts index 32ee816..81f11e6 100644 --- a/source/utils/utils.ts +++ b/source/utils/utils.ts @@ -1,13 +1,8 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' -import { - getDefaultFeatureNames, - getFeatureEntries, - getFeatureNames, - getStackConfig, -} from '../stacks/index.js' -import type { FeatureName, InstallationType, PlanSummaryItem, Stack } from '../types/types.js' +import { featureDefinitions, featureEntries, featureNames } from '../config.js' +import type { FeatureName, InstallationType, PlanSummaryItem } from '../types/types.js' export function getProjectFolder(projectName: string) { return join(process.cwd(), projectName) @@ -31,114 +26,19 @@ export function isFeatureSelected(feature: FeatureName, selectedFeatures: Featur return selectedFeatures.includes(feature) } -/** Whether the running Node is at least `required`, both written as dotted numbers. */ -export function meetsNodeVersion(required: string, current = process.versions.node): boolean { - const numbers = (version: string) => version.split('.').map((part) => Number.parseInt(part, 10)) - const wanted = numbers(required) - const running = numbers(current) - - for (const [index, want] of wanted.entries()) { - const have = running[index] ?? 0 - if (have !== want) { - return have > want - } - } - - return true -} - -type FeatureToggleAction = 'select' | 'unselect' - -/** Walks a feature's `requires` chain, adding every transitive requirement to `accumulator`. */ -function collectRequiredFeatures( - stack: Stack, - feature: FeatureName, - accumulator: Set, -): void { - const definition = getStackConfig(stack).features[feature] - if (!definition?.requires) { - return - } - - for (const required of definition.requires) { - if (accumulator.has(required)) { - continue - } - - accumulator.add(required) - collectRequiredFeatures(stack, required, accumulator) - } -} - -/** Features that depend on `target`, directly or through another one. They go when it goes. */ -function getDependentFeatures(stack: Stack, target: FeatureName): Set { - const dependents = new Set() - - for (const name of getFeatureNames(stack)) { - const required = new Set() - collectRequiredFeatures(stack, name, required) - if (required.has(target)) { - dependents.add(name) - } - } - - return dependents -} - -/** Expands a selection to include every transitive requirement, returned in config order. */ -export function resolveSelectedFeatures( - stack: Stack, - selectedFeatures: FeatureName[], -): FeatureName[] { - const resolved = new Set(selectedFeatures) - for (const feature of selectedFeatures) { - collectRequiredFeatures(stack, feature, resolved) - } - - return getFeatureNames(stack).filter((name) => resolved.has(name)) -} - -/** - * Interactive toggle that keeps the selection consistent: selecting a feature pulls its - * requirements in, unselecting one drops its dependents. Result is in config order. - */ -export function applyFeatureToggle( - stack: Stack, - selectedFeatures: FeatureName[], - toggledFeature: FeatureName, - action: FeatureToggleAction, -): FeatureName[] { - if (action === 'select') { - return resolveSelectedFeatures(stack, [...selectedFeatures, toggledFeature]) - } - - const toRemove = getDependentFeatures(stack, toggledFeature) - toRemove.add(toggledFeature) - - return getFeatureNames(stack).filter( - (name) => selectedFeatures.includes(name) && !toRemove.has(name), - ) -} - /** How each mode is named on screen, in the selector and in the review that follows it. */ export const MODE_LABELS: Record = { - default: 'Default (recommended)', full: 'Full', custom: 'Custom', } -/** - * The plan as one item per setting, shown on the confirmation step before any disk work begins. - * Only stacks that ask a question reach that step. - */ +/** The plan as one item per setting, shown on the confirmation step before any disk work begins. */ export function describeInstallPlan( - stack: Stack, projectName: string, mode: InstallationType, selectedFeatures: FeatureName[], ): PlanSummaryItem[] { const items: PlanSummaryItem[] = [ - { label: 'Stack', value: getStackConfig(stack).label }, { label: 'Project', value: projectName }, { label: 'Mode', value: MODE_LABELS[mode] }, ] @@ -151,41 +51,28 @@ export function describeInstallPlan( return [...items, { label: 'Features', value: features }] } -export function getPackagesToRemove(stack: Stack, selectedFeatures: FeatureName[]): string[] { - return getFeatureEntries(stack) +export function getPackagesToRemove(selectedFeatures: FeatureName[]): string[] { + return featureEntries .filter(([name]) => !selectedFeatures.includes(name)) .flatMap(([, definition]) => definition.packages) } -/** Post-install guidance for a scaffold: the stack's own, then that of each feature it kept. */ -export function getPostInstallMessages(stack: Stack, features: FeatureName[]): string[] { - const config = getStackConfig(stack) - - return [ - ...(config.postInstall ?? []), - ...features.flatMap((name) => config.features[name]?.postInstall ?? []), - ] +/** Post-install guidance for a scaffold: that of each feature it kept. */ +export function getPostInstallMessages(features: FeatureName[]): string[] { + return features.flatMap((name) => featureDefinitions[name].postInstall ?? []) } /** - * The features a mode keeps: full → all of them, default → the ones on by default, custom → the - * user's own selection. Both selections come back with their `requires` resolved. Shared by the - * non-interactive path and the interactive steps. + * The features a mode keeps, in config order: full → all of them, custom → the user's own + * selection. Shared by the non-interactive path and the interactive steps. */ export function resolveModeFeatures( - stack: Stack, mode: InstallationType, customSelection: FeatureName[] = [], ): FeatureName[] { - if (mode === 'full') { - return getFeatureNames(stack) - } - - if (mode === 'default') { - return resolveSelectedFeatures(stack, getDefaultFeatureNames(stack)) - } - - return resolveSelectedFeatures(stack, customSelection) + return mode === 'full' + ? featureNames + : featureNames.filter((name) => isFeatureSelected(name, customSelection)) } export function projectDirectoryExists(projectName: string): boolean {