Skip to content

Commit ed45625

Browse files
refactor: clean up before installing, and share one step runner
Cleanup ran after the install, so it had to repair the lockfile the package manager had just written. Running it first means the manifest is already pruned when the install starts: one dependency resolution instead of two, nothing installed only to be deleted, and a lockfile that matches by construction. The lockfile repair pass is gone. - One way to remove a dependency. Features declare `packages`, which the package manager uninstalls. The second field that hand-edited package.json existed only because the install always ran the template's `postinstall` script; that call now happens only when the script exists, so Canton can use the same field as EVM. - Cleanup is one code path for both stacks. The paths a stack always removes, where it stages replacement files, and whether it commits the scaffold are `StackConfig` fields, not `stack === 'canton'` branches. The baseline commit moved to its own operation, which runs last. - The three operation steps were the same component three times, down to a byte-identical progress block. They now share `StepProgress`, which owns the progress list, the error display and the failure path, so a new step cannot forget to report a failure. - Feature names are typed inside the config too, so a mistyped `requires` or `ifFeature` fails to compile instead of being skipped at runtime. - Validation reads the modes a stack accepts from the config, which is the same list `--info` publishes. - One place prints the JSON failure envelope, and the caller recognises an already-reported error by type instead of by reading the exit code.
1 parent e16ce9f commit ed45625

27 files changed

Lines changed: 560 additions & 556 deletions

‎architecture.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ everything.
66

77
| Doc | Read it when you're… | Covers |
88
|---|---|---|
9-
| [abstractions](./docs/architecture/abstractions.md) | touching the config model, operations, or shell exec | `Stack`/`StackConfig`, `FeatureDefinition` (`paths`, `scripts`, `dependencies`, `requires`), operations layer, `exec`/`execFile`, security |
9+
| [abstractions](./docs/architecture/abstractions.md) | touching the config model, operations, or shell exec | `Stack`/`StackConfig` (`hygiene`, `staging`, `initialCommit`), `FeatureDefinition` (`paths`, `scripts`, `packages`, `requires`), operations layer, `exec`/`execFile`, security |
1010
| [data-flow](./docs/architecture/data-flow.md) | changing CLI routing or the step sequence | non-interactive validation/execution order, JSON output, interactive step flow |
1111
| [extending](./docs/architecture/extending.md) | adding a stack, feature, or operation | step-by-step checklists for each |
1212

@@ -38,7 +38,8 @@ source/
3838
cloneRepo.ts Clone (tag-latest OR branch), apply stack.removeAfterClone, rm .git, git init
3939
createEnvFile.ts Copy each stack's envFiles (with optional ifFeature gate)
4040
installPackages.ts Stack-aware: uses stack.packageManager (pnpm or npm)
41-
cleanupFiles.ts Removes deselected features, patches package.json, refreshes the lockfile
41+
cleanupFiles.ts Removes deselected features and patches package.json, before the install
42+
createInitialCommit.ts Commits the finished scaffold (stacks that ask for it)
4243
installGuard.ts Removes the partial project dir if interrupted mid-scaffold
4344
index.ts Barrel export
4445
components/
@@ -48,8 +49,9 @@ source/
4849
CloneRepo/CloneRepo.tsx Clone progress display (receives stack)
4950
InstallationMode.tsx Mode selection (Canton: Default/Full/Custom; EVM: Full/Custom)
5051
OptionalPackages.tsx Feature multiselect (per-stack; pre-checks default:true features)
51-
Install/Install.tsx Install progress display (receives stack)
52-
FileCleanup.tsx Cleanup progress display (receives stack)
52+
FileCleanup.tsx Cleanup progress display, runs before the install
53+
Install/Install.tsx Env files, package install and baseline commit
54+
StepProgress.tsx Shared runner for the operation steps: progress, errors, guard
5355
PostInstall.tsx Post-install instructions, stack-specific
5456
Ask.tsx Text input with validation
5557
Divider.tsx Section divider

‎docs/architecture/abstractions.md‎

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ type StackConfig = {
1717
packageManager: 'pnpm' | 'npm'
1818
removeAfterClone: string[] // paths nuked between clone and `git init` (empty for both stacks today)
1919
postInstall?: string[] // stack-level post-install guidance, shown for every scaffold (Canton run steps)
20-
envFiles: Array<{ from: string; to: string; ifFeature?: string }>
20+
hygiene?: { label: string; paths: string[] } // the template's own repo files, removed from every scaffold (EVM only)
21+
staging?: { label: string; paths: string[] } // where the template keeps replacement files; removed once cleanup is done (EVM's .install-files)
22+
initialCommit?: boolean // commit the finished scaffold as the project's baseline (Canton)
23+
envFiles: Array<{ from: string; to: string; ifFeature?: FeatureName }>
2124
features: Record<string, FeatureDefinition>
2225
}
2326
```
@@ -28,7 +31,7 @@ Installation modes are stack-aware via `getInstallationModes(stack)` — Canton
2831
2932
`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.
3033
31-
`FeatureName` is derived from `stackDefinitions` (which is declared with `satisfies`, so the literal keys survive), giving the union of every feature name both stacks define. Renaming a feature in the map 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`. The type is deliberately not per-stack: passing an EVM feature name to a Canton call still compiles, and the runtime check in `nonInteractive.ts` catches it.
34+
`FeatureName` is the union of every feature name both stacks define, taken from the `featureNamesByStack` list at the top of `config.ts`. The list exists so the config can refer to its own feature names by type (`requires`, `ifFeature`) without a circular reference; the `satisfies` clause on `stackDefinitions` requires the feature maps to hold exactly those keys, so the two 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`. The type is deliberately not per-stack: passing an EVM feature name to a Canton call still compiles, and the runtime check in `nonInteractive.ts` catches it.
3235
3336
## Feature Definitions
3437
@@ -38,21 +41,20 @@ Stored inside each stack's `features` map. Shape:
3841
type FeatureDefinition = {
3942
description: string // --info output
4043
label: string // TUI multiselect display
41-
packages: string[] // package-manager packages to remove when deselected (empty for canton features today)
44+
packages: string[] // dependencies the package manager removes when the feature is deselected
4245
default: boolean // --info output
4346
postInstall?: string[] // post-install instructions for non-interactive JSON output
4447
paths?: string[] // files/dirs removed when the feature is deselected
4548
scripts?: string[] // package.json scripts removed when the feature is deselected
46-
dependencies?: string[] // deps deleted straight from package.json (the package manager is not asked to uninstall them)
47-
requires?: string[] // features this one depends on (one-directional, transitive)
49+
requires?: FeatureName[] // features this one depends on (one-directional, transitive)
4850
}
4951
```
5052
51-
When adding a new feature, add it to the relevant stack's `features` map. Programmatic consumers pick it up automatically. Feature cleanup is data-driven from `paths`, `scripts` and `dependencies` for both stacks (see the Operations Layer below), so a new feature usually needs no cleanup code. The two exceptions are EVM's `demo` and `subgraph`, which restore replacement source files from the template's `.install-files` directory. The CLI `--help` text in `cli.tsx` maintains its own copy in both cases.
53+
When adding a new feature, add its name to `featureNamesByStack` and an entry to the stack's `features` map. Programmatic consumers pick it up automatically. Feature cleanup is data-driven from `paths` and `scripts` for both stacks, 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.
5254
53-
`packages` and `dependencies` differ in who removes them. `packages` are handed to the package manager (`pnpm remove` / `npm uninstall`), which updates package.json and the lockfile together. `dependencies` are deleted from package.json by cleanup, which then rewrites the lockfile itself. Canton uses the second form because its template has no `postinstall` script for the package manager's remove path to run.
55+
`packages` are always removed by the package manager (`pnpm remove` / `npm uninstall`), which updates package.json and the lockfile together. Nothing hand-edits dependencies.
5456
55-
**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) and is shared by the non-interactive path and the interactive Install/FileCleanup/PostInstall steps. `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.
57+
**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.
5658
5759
## Operations Layer (`source/operations/`)
5860
@@ -62,12 +64,12 @@ Plain async functions, no UI dependencies. Each operation that varies per stack
6264
|---|---|
6365
| `cloneRepo(stack, projectName, onProgress?)` | Reads `stack.refType`. **tag-latest**: shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). **branch**: shallow clone with `--branch <stack.ref> --single-branch` (no shell). After that, runs `fs.rm` for every entry in `stack.removeAfterClone` (empty for both stacks today), removes `.git`, and reinitializes with `git init`. Uses `execFile` everywhere except the tag-latest shell substitution. |
6466
| `createEnvFile(stack, projectFolder, features?)` | Copies every entry from `stack.envFiles`. Entries with `ifFeature` are skipped unless the named feature is in the selection (e.g. Canton's `carpincho-wallet/.env.local` only when `carpincho` is selected). |
65-
| `installPackages(stack, projectFolder, mode, features, onProgress?)` | Uses `stack.packageManager`. Full: `<pm> install`. `default`/`custom` with packages to remove: `<pm> remove` (pnpm) or `<pm> uninstall` (npm) + `<pm> run postinstall`; with nothing to remove: `<pm> install`. Canton features all carry `packages: []`, so Canton always runs a plain `npm install` (husky-dep removal happens in cleanup, not here — the Canton template has no `postinstall` script). `execFile` only — never shell. |
66-
| `cleanupFiles(stack, projectFolder, mode, features, onProgress?)` | **EVM** starts with the hygiene it always applies: `.github` (the template's CI) and its own agent metadata (`.claude`, `AGENTS.md`, `CLAUDE.md`, `architecture.md`). It then restores the replacement home page from `.install-files` when `demo` or `subgraph` is deselected, and finally deletes the `.install-files` staging directory. **Canton** applies no forced hygiene — `.github` and the pre-commit automation are its optional `github` and `precommit` features. Everything else is **data-driven for both stacks**: in `default` and `custom` modes (not `full`) it loops the stack's features and, for each one the user left out, removes its `paths` and collects its `scripts` and `dependencies`. Removed **directories** then drive two more `package.json` edits: **script stripping** by command target — any script whose command invokes a removed directory is dropped, so deselecting `carpincho` strips `wallet:dev` and `carpincho:build:extension` — and **`workspaces` pruning**, dropping any workspace entry pointing at a removed directory (both the `string[]` and `{ packages: string[] }` forms). Removed files never strip scripts, so a script that merely mentions `CLAUDE.md` survives. package.json is read once and written only when something changed. When the dependencies or the workspaces list changed, the lockfile is rewritten from the new manifest (`npm install --package-lock-only` / `pnpm install --lockfile-only`); a failure there is reported through `onProgress` and does not fail the scaffold. In `full` mode nothing is removed, so a full scaffold keeps every feature. Canton then makes an initial `git` commit of the scaffold. |
67+
| `installPackages(stack, projectFolder, mode, features, onProgress?)` | Uses `stack.packageManager`. Nothing to remove (full mode, or a selection that drops no packages): `<pm> install`. Otherwise `<pm> remove` (pnpm) or `<pm> uninstall` (npm), which prunes the manifest and the lockfile together, then `<pm> 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. |
68+
| `cleanupFiles(stack, projectFolder, mode, features, onProgress?)` | Config-driven for both stacks, and runs **before** the install. First the stack's `hygiene` group, the paths belonging to the template's own repository (EVM: `.github` plus `.claude`, `AGENTS.md`, `CLAUDE.md`, `architecture.md`; Canton declares none, since it models those as its `github` and `llm` features). 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** drive two further package.json edits: **script stripping** by command target — any script whose command invokes a removed directory is dropped, so dropping `carpincho` strips `wallet:dev` and `carpincho:build:extension` — and **`workspaces` pruning**, dropping any entry pointing at a removed directory (both the `string[]` and `{ packages: string[] }` forms). Removed *files* never strip scripts, so a script that merely mentions `CLAUDE.md` survives. package.json is read once and written only when a value changed; dependencies are left to `installPackages`. EVM additionally restores the demo-free home page from the staged copies when `demo` or `subgraph` is dropped. Last comes the stack's `staging` group (EVM's `.install-files`), once the restores no longer need it. |
6769
6870
### Interrupt safety (`installGuard`)
6971
70-
`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 `FileCleanup` calls `completeInstall`.
72+
`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`.
7173
7274
## Shell Execution (`source/operations/exec.ts`)
7375

0 commit comments

Comments
 (0)