Skip to content

Commit 27aad30

Browse files
authored
Merge pull request #9 from BootNodeDev/release/v3.3.2
Release v3.3.2 — fix Canton dangling workspace entry on feature deselection
2 parents f1a72cf + bcd940e commit 27aad30

4 files changed

Lines changed: 151 additions & 26 deletions

File tree

docs/architecture/abstractions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ Plain async functions, no UI dependencies. Each operation that varies per stack
5757
| `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. |
5858
| `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). |
5959
| `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. |
60-
| `cleanupFiles(stack, projectFolder, mode, features, onProgress?)` | **EVM** runs **repository hygiene** first (always): removes `.github` (CI), the husky/commitlint automation (`.husky`, `.lintstagedrc.mjs`, `commitlint.config.js`), and its own agent metadata (`.claude`, `AGENTS.md`, `CLAUDE.md`, `architecture.md`), and sanitizes tooling deps/scripts from `package.json`; then `cleanupEvmFiles` removes deselected feature files via per-feature functions plus the `.install-files` staging directory. **Canton** runs **no forced hygiene** — `.github` and the pre-commit automation are the optional `github` and `precommit` features. `cleanupCantonFiles` is **data-driven**: for `default` and `custom` modes (not `full`) it loops the stack's features and removes each deselected feature's `paths` (`github` → `.github`; `precommit` → the husky files; `carpincho` → `carpincho-wallet`; `llm` → the agent/LLM artifacts). Removed directories drive `package.json` script stripping by **command target** — any script whose command invokes a removed directory is dropped (so deselecting `carpincho` strips `wallet:dev` / `carpincho:build:extension`). When `precommit` is removed it additionally strips the `prepare`/commitlint scripts and the husky/lint-staged/commitlint dev-dependencies. In `full` mode nothing is removed, so a full Canton scaffold keeps `.github`, the hooks, `carpincho-wallet`, and the agent docs. Canton then makes an initial `git` commit of the scaffold. |
60+
| `cleanupFiles(stack, projectFolder, mode, features, onProgress?)` | **EVM** runs **repository hygiene** first (always): removes `.github` (CI), the husky/commitlint automation (`.husky`, `.lintstagedrc.mjs`, `commitlint.config.js`), and its own agent metadata (`.claude`, `AGENTS.md`, `CLAUDE.md`, `architecture.md`), and sanitizes tooling deps/scripts from `package.json`; then `cleanupEvmFiles` removes deselected feature files via per-feature functions plus the `.install-files` staging directory. **Canton** runs **no forced hygiene** — `.github` and the pre-commit automation are the optional `github` and `precommit` features. `cleanupCantonFiles` is **data-driven**: for `default` and `custom` modes (not `full`) it loops the stack's features and removes each deselected feature's `paths` (`github` → `.github`; `precommit` → the husky files; `carpincho` → `carpincho-wallet`; `llm` → the agent/LLM artifacts). Removed directories drive two `package.json` edits: **script stripping** by command target — any script whose command invokes a removed directory is dropped (so deselecting `carpincho` strips `wallet:dev` / `carpincho:build:extension`) — and **`workspaces` pruning**, dropping any workspace entry that points at a removed directory (both the `string[]` and `{ packages: string[] }` forms), so deselecting `carpincho` leaves no dangling `carpincho-wallet` workspace. When `precommit` is removed it additionally strips the `prepare`/commitlint scripts and the husky/lint-staged/commitlint dev-dependencies. In `full` mode nothing is removed, so a full Canton scaffold keeps `.github`, the hooks, `carpincho-wallet`, and the agent docs. Canton then makes an initial `git` commit of the scaffold. |
6161
6262
### Interrupt safety (`installGuard`)
6363

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "dappbooster",
3-
"version": "3.3.1",
3+
"version": "3.3.2",
44
"description": "Agent-friendly dAppBooster installer that scaffolds Web3 dApps via TUI or non-interactive CLI/CI.",
55
"keywords": [
66
"dappbooster",

source/__tests__/operations/cleanupFiles.test.ts

Lines changed: 122 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { resolve } from 'node:path'
22
import { beforeEach, describe, expect, it, vi } from 'vitest'
3-
import type { FeatureName } from '../../constants/config.js'
3+
import { type FeatureName, getStackConfig } from '../../constants/config.js'
44

55
vi.mock('node:fs/promises', () => ({
66
rm: vi.fn().mockResolvedValue(undefined),
@@ -58,6 +58,30 @@ function getWrittenPackageJson(): Record<string, unknown> {
5858
return JSON.parse(lastCall[1] as string)
5959
}
6060

61+
// Reads the workspaces field in either form (string[] or { packages }).
62+
function getWorkspacePackages(pkg: Record<string, unknown>): string[] {
63+
const workspaces = pkg.workspaces
64+
65+
if (Array.isArray(workspaces)) {
66+
return workspaces as string[]
67+
}
68+
69+
const packages = (workspaces as { packages?: unknown } | undefined)?.packages
70+
return Array.isArray(packages) ? (packages as string[]) : []
71+
}
72+
73+
// Dirs removed for a selection, derived from config — keeps the invariant config-driven instead of
74+
// hardcoding carpincho-wallet.
75+
function removedCantonDirs(selected: FeatureName[]): string[] {
76+
return Object.entries(getStackConfig('canton').features)
77+
.filter(([name, definition]) => !selected.includes(name) && (definition.paths?.length ?? 0) > 0)
78+
.flatMap(([, definition]) => definition.paths as string[])
79+
}
80+
81+
function entryTargetsRemovedDir(entry: string, removedDirs: string[]): boolean {
82+
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
83+
}
84+
6185
function mockEvmPackageJson() {
6286
vi.mocked(readFileSync).mockReturnValue(
6387
JSON.stringify({
@@ -75,31 +99,53 @@ function mockEvmPackageJson() {
7599
)
76100
}
77101

78-
// Mirrors the real root package.json on BootNodeDev/cn-dappbooster@main.
79-
function mockCantonPackageJson() {
102+
// Mirrors cn-dappbooster@main's root package.json, including the workspaces array — carpincho-wallet
103+
// is a workspace, so deselecting carpincho must prune it.
104+
const CANTON_WORKSPACES = [
105+
'canton-connect-kit',
106+
'carpincho-wallet',
107+
'canton-barebones',
108+
'canton-barebones/wallet-service',
109+
'dapp/daml',
110+
'dapp/e2e',
111+
'dapp/frontend',
112+
]
113+
114+
const CANTON_SCRIPTS = {
115+
'canton:up': 'npm --prefix canton-barebones run up',
116+
'canton:down': 'npm --prefix canton-barebones run down',
117+
'canton:health': 'npm --prefix canton-barebones run health',
118+
'canton:token': 'npm --prefix canton-barebones run token',
119+
'build-dar': 'bash scripts/build-dar.sh',
120+
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
121+
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
122+
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
123+
'wallet-service:health': 'curl -fsS http://localhost:3010/health',
124+
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
125+
'app:dev': 'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
126+
lint: 'biome check',
127+
'lint:fix': 'biome check --write',
128+
format: 'biome format --write',
129+
e2e: 'npm --prefix dapp/e2e test',
130+
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
131+
'e2e:ui': 'npm --prefix dapp/e2e run test:ui',
132+
prepare: 'husky',
133+
}
134+
135+
const CANTON_DEV_DEPS = {
136+
husky: '^9.1.7',
137+
'lint-staged': '^17.0.4',
138+
'@commitlint/cli': '^21.0.1',
139+
'@commitlint/config-conventional': '^21.0.1',
140+
}
141+
142+
// Pass { packages } to exercise the object form; defaults to the string[] form.
143+
function mockCantonPackageJson(workspaces: unknown = CANTON_WORKSPACES) {
80144
vi.mocked(readFileSync).mockReturnValue(
81145
JSON.stringify({
82-
scripts: {
83-
'canton:up': 'npm --prefix canton-barebones run up',
84-
'canton:down': 'npm --prefix canton-barebones run down',
85-
'build-dar': 'bash scripts/build-dar.sh',
86-
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
87-
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
88-
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
89-
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
90-
'app:dev':
91-
'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
92-
lint: 'biome check',
93-
e2e: 'npm --prefix dapp/e2e test',
94-
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
95-
prepare: 'husky',
96-
},
97-
devDependencies: {
98-
husky: '^9.1.7',
99-
'lint-staged': '^17.0.4',
100-
'@commitlint/cli': '^21.0.1',
101-
'@commitlint/config-conventional': '^21.0.1',
102-
},
146+
workspaces,
147+
scripts: CANTON_SCRIPTS,
148+
devDependencies: CANTON_DEV_DEPS,
103149
}),
104150
)
105151
}
@@ -411,6 +457,12 @@ describe('cleanupFiles — canton', () => {
411457
expect(execFile).toHaveBeenCalledWith('git', ['add', '.'], { cwd: '/project/my_app' })
412458
})
413459

460+
it('keeps the full workspaces array (nothing removed)', async () => {
461+
await cleanupFiles('canton', '/project/my_app', 'full')
462+
463+
expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
464+
})
465+
414466
it('makes the initial commit with --no-verify so kept project hooks cannot block it', async () => {
415467
await cleanupFiles('canton', '/project/my_app', 'full')
416468

@@ -496,6 +548,41 @@ describe('cleanupFiles — canton', () => {
496548
expect(scripts['wallet:dev']).toBeUndefined()
497549
expect(scripts['carpincho:build:extension']).toBeUndefined()
498550
})
551+
552+
it('prunes carpincho-wallet from the workspaces array but keeps the rest', async () => {
553+
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])
554+
555+
const workspaces = getWorkspacePackages(getWrittenPackageJson())
556+
expect(workspaces).not.toContain('carpincho-wallet')
557+
expect(workspaces).toContain('canton-barebones')
558+
expect(workspaces).toContain('dapp/frontend')
559+
})
560+
561+
// The invariant the bug violated — asserted generally, not just for carpincho-wallet.
562+
it('leaves no workspace entry pointing at a removed directory', async () => {
563+
const selected: FeatureName[] = ['github', 'precommit', 'llm']
564+
await cleanupFiles('canton', '/project/my_app', 'custom', selected)
565+
566+
const removedDirs = removedCantonDirs(selected)
567+
const workspaces = getWorkspacePackages(getWrittenPackageJson())
568+
for (const entry of workspaces) {
569+
expect(entryTargetsRemovedDir(entry, removedDirs)).toBe(false)
570+
}
571+
// Guard against the array being emptied wholesale.
572+
expect(workspaces.length).toBeGreaterThan(0)
573+
})
574+
575+
it('prunes the { packages } object form and preserves sibling keys', async () => {
576+
mockCantonPackageJson({ packages: CANTON_WORKSPACES, nohoist: ['**/react'] })
577+
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])
578+
579+
const written = getWrittenPackageJson()
580+
expect(Array.isArray(written.workspaces)).toBe(false)
581+
const workspaces = written.workspaces as { packages: string[]; nohoist: string[] }
582+
expect(workspaces.packages).not.toContain('carpincho-wallet')
583+
expect(workspaces.packages).toContain('dapp/frontend')
584+
expect(workspaces.nohoist).toEqual(['**/react'])
585+
})
499586
})
500587

501588
describe('custom mode — llm deselected', () => {
@@ -512,6 +599,17 @@ describe('cleanupFiles — canton', () => {
512599
expect(paths).toContain(resolve('/project/my_app', 'architecture.md'))
513600
expect(paths).toContain(resolve('/project/my_app', 'llms.txt'))
514601
})
602+
603+
// llm's paths aren't workspaces — removing it must not touch the array.
604+
it('leaves the workspaces array intact (its paths are not workspaces)', async () => {
605+
await cleanupFiles('canton', '/project/my_app', 'custom', [
606+
'github',
607+
'precommit',
608+
'carpincho',
609+
])
610+
611+
expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
612+
})
515613
})
516614

517615
describe('onProgress callback', () => {

source/operations/cleanupFiles.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,30 @@ function scriptTargetsRemovedDir(command: string, removedDirs: string[]): boolea
164164
)
165165
}
166166

167+
function workspaceEntryRemoved(entry: string, removedDirs: string[]): boolean {
168+
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
169+
}
170+
171+
// Drop workspaces entries pointing at a removed dir. Handles both the string[] and { packages }
172+
// forms (other object keys preserved); mutates in place.
173+
function pruneRemovedWorkspaces(
174+
packageJson: { workspaces?: string[] | { packages?: string[] } },
175+
removedDirs: string[],
176+
): void {
177+
if (removedDirs.length === 0) {
178+
return
179+
}
180+
181+
const { workspaces } = packageJson
182+
const keep = (entry: string): boolean => !workspaceEntryRemoved(entry, removedDirs)
183+
184+
if (Array.isArray(workspaces)) {
185+
packageJson.workspaces = workspaces.filter(keep)
186+
} else if (workspaces && Array.isArray(workspaces.packages)) {
187+
workspaces.packages = workspaces.packages.filter(keep)
188+
}
189+
}
190+
167191
function patchPackageJsonCanton(
168192
projectFolder: string,
169193
removedDirs: string[],
@@ -181,6 +205,9 @@ function patchPackageJsonCanton(
181205
}
182206
}
183207

208+
// Removed dirs also drop out of the workspaces array, else the manifest lists a missing dir.
209+
pruneRemovedWorkspaces(packageJson, removedDirs)
210+
184211
// The husky tooling (prepare/commitlint scripts + husky/lint-staged/commitlint deps) only leaves
185212
// with the pre-commit feature.
186213
if (precommitRemoved) {

0 commit comments

Comments
 (0)