Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/abstractions.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ Plain async functions, no UI dependencies. Each operation that varies per stack
| `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. |
| `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). |
| `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. |
| `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. |
| `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. |

### Interrupt safety (`installGuard`)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "dappbooster",
"version": "3.3.1",
"version": "3.3.2",
"description": "Agent-friendly dAppBooster installer that scaffolds Web3 dApps via TUI or non-interactive CLI/CI.",
"keywords": [
"dappbooster",
Expand Down
146 changes: 122 additions & 24 deletions source/__tests__/operations/cleanupFiles.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { resolve } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FeatureName } from '../../constants/config.js'
import { type FeatureName, getStackConfig } from '../../constants/config.js'

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

// Reads the workspaces field in either form (string[] or { packages }).
function getWorkspacePackages(pkg: Record<string, unknown>): string[] {
const workspaces = pkg.workspaces

if (Array.isArray(workspaces)) {
return workspaces as string[]
}

const packages = (workspaces as { packages?: unknown } | undefined)?.packages
return Array.isArray(packages) ? (packages as string[]) : []
}

// Dirs removed for a selection, derived from config — keeps the invariant config-driven instead of
// hardcoding carpincho-wallet.
function removedCantonDirs(selected: FeatureName[]): string[] {
return Object.entries(getStackConfig('canton').features)
.filter(([name, definition]) => !selected.includes(name) && (definition.paths?.length ?? 0) > 0)
.flatMap(([, definition]) => definition.paths as string[])
}

function entryTargetsRemovedDir(entry: string, removedDirs: string[]): boolean {
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
}

function mockEvmPackageJson() {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
Expand All @@ -75,31 +99,53 @@ function mockEvmPackageJson() {
)
}

// Mirrors the real root package.json on BootNodeDev/cn-dappbooster@main.
function mockCantonPackageJson() {
// Mirrors cn-dappbooster@main's root package.json, including the workspaces array — carpincho-wallet
// is a workspace, so deselecting carpincho must prune it.
const CANTON_WORKSPACES = [
'canton-connect-kit',
'carpincho-wallet',
'canton-barebones',
'canton-barebones/wallet-service',
'dapp/daml',
'dapp/e2e',
'dapp/frontend',
]

const CANTON_SCRIPTS = {
'canton:up': 'npm --prefix canton-barebones run up',
'canton:down': 'npm --prefix canton-barebones run down',
'canton:health': 'npm --prefix canton-barebones run health',
'canton:token': 'npm --prefix canton-barebones run token',
'build-dar': 'bash scripts/build-dar.sh',
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
'wallet-service:health': 'curl -fsS http://localhost:3010/health',
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
'app:dev': 'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
lint: 'biome check',
'lint:fix': 'biome check --write',
format: 'biome format --write',
e2e: 'npm --prefix dapp/e2e test',
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
'e2e:ui': 'npm --prefix dapp/e2e run test:ui',
prepare: 'husky',
}

const CANTON_DEV_DEPS = {
husky: '^9.1.7',
'lint-staged': '^17.0.4',
'@commitlint/cli': '^21.0.1',
'@commitlint/config-conventional': '^21.0.1',
}

// Pass { packages } to exercise the object form; defaults to the string[] form.
function mockCantonPackageJson(workspaces: unknown = CANTON_WORKSPACES) {
vi.mocked(readFileSync).mockReturnValue(
JSON.stringify({
scripts: {
'canton:up': 'npm --prefix canton-barebones run up',
'canton:down': 'npm --prefix canton-barebones run down',
'build-dar': 'bash scripts/build-dar.sh',
'deploy-dar': 'bash canton-barebones/scripts/deploy-dar.sh',
'wallet:dev': 'npm --prefix carpincho-wallet run dev',
'wallet-service:dev': 'npm --prefix canton-barebones/wallet-service run dev',
'carpincho:build:extension': 'npm --prefix carpincho-wallet run build:extension',
'app:dev':
'npm --prefix dapp/frontend run dev -- --host localhost --port 3012 --strictPort',
lint: 'biome check',
e2e: 'npm --prefix dapp/e2e test',
'e2e:headed': 'npm --prefix dapp/e2e run test:headed',
prepare: 'husky',
},
devDependencies: {
husky: '^9.1.7',
'lint-staged': '^17.0.4',
'@commitlint/cli': '^21.0.1',
'@commitlint/config-conventional': '^21.0.1',
},
workspaces,
scripts: CANTON_SCRIPTS,
devDependencies: CANTON_DEV_DEPS,
}),
)
}
Expand Down Expand Up @@ -411,6 +457,12 @@ describe('cleanupFiles — canton', () => {
expect(execFile).toHaveBeenCalledWith('git', ['add', '.'], { cwd: '/project/my_app' })
})

it('keeps the full workspaces array (nothing removed)', async () => {
await cleanupFiles('canton', '/project/my_app', 'full')

expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
})

it('makes the initial commit with --no-verify so kept project hooks cannot block it', async () => {
await cleanupFiles('canton', '/project/my_app', 'full')

Expand Down Expand Up @@ -496,6 +548,41 @@ describe('cleanupFiles — canton', () => {
expect(scripts['wallet:dev']).toBeUndefined()
expect(scripts['carpincho:build:extension']).toBeUndefined()
})

it('prunes carpincho-wallet from the workspaces array but keeps the rest', async () => {
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])

const workspaces = getWorkspacePackages(getWrittenPackageJson())
expect(workspaces).not.toContain('carpincho-wallet')
expect(workspaces).toContain('canton-barebones')
expect(workspaces).toContain('dapp/frontend')
})

// The invariant the bug violated — asserted generally, not just for carpincho-wallet.
it('leaves no workspace entry pointing at a removed directory', async () => {
const selected: FeatureName[] = ['github', 'precommit', 'llm']
await cleanupFiles('canton', '/project/my_app', 'custom', selected)

const removedDirs = removedCantonDirs(selected)
const workspaces = getWorkspacePackages(getWrittenPackageJson())
for (const entry of workspaces) {
expect(entryTargetsRemovedDir(entry, removedDirs)).toBe(false)
}
// Guard against the array being emptied wholesale.
expect(workspaces.length).toBeGreaterThan(0)
})

it('prunes the { packages } object form and preserves sibling keys', async () => {
mockCantonPackageJson({ packages: CANTON_WORKSPACES, nohoist: ['**/react'] })
await cleanupFiles('canton', '/project/my_app', 'custom', ['github', 'precommit', 'llm'])

const written = getWrittenPackageJson()
expect(Array.isArray(written.workspaces)).toBe(false)
const workspaces = written.workspaces as { packages: string[]; nohoist: string[] }
expect(workspaces.packages).not.toContain('carpincho-wallet')
expect(workspaces.packages).toContain('dapp/frontend')
expect(workspaces.nohoist).toEqual(['**/react'])
})
})

describe('custom mode — llm deselected', () => {
Expand All @@ -512,6 +599,17 @@ describe('cleanupFiles — canton', () => {
expect(paths).toContain(resolve('/project/my_app', 'architecture.md'))
expect(paths).toContain(resolve('/project/my_app', 'llms.txt'))
})

// llm's paths aren't workspaces — removing it must not touch the array.
it('leaves the workspaces array intact (its paths are not workspaces)', async () => {
await cleanupFiles('canton', '/project/my_app', 'custom', [
'github',
'precommit',
'carpincho',
])

expect(getWorkspacePackages(getWrittenPackageJson())).toEqual(CANTON_WORKSPACES)
})
})

describe('onProgress callback', () => {
Expand Down
27 changes: 27 additions & 0 deletions source/operations/cleanupFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,30 @@ function scriptTargetsRemovedDir(command: string, removedDirs: string[]): boolea
)
}

function workspaceEntryRemoved(entry: string, removedDirs: string[]): boolean {
return removedDirs.some((dir) => entry === dir || entry.startsWith(`${dir}/`))
}

// Drop workspaces entries pointing at a removed dir. Handles both the string[] and { packages }
// forms (other object keys preserved); mutates in place.
function pruneRemovedWorkspaces(
packageJson: { workspaces?: string[] | { packages?: string[] } },
removedDirs: string[],
): void {
if (removedDirs.length === 0) {
return
}

const { workspaces } = packageJson
const keep = (entry: string): boolean => !workspaceEntryRemoved(entry, removedDirs)

if (Array.isArray(workspaces)) {
packageJson.workspaces = workspaces.filter(keep)
} else if (workspaces && Array.isArray(workspaces.packages)) {
workspaces.packages = workspaces.packages.filter(keep)
}
}

function patchPackageJsonCanton(
projectFolder: string,
removedDirs: string[],
Expand All @@ -181,6 +205,9 @@ function patchPackageJsonCanton(
}
}

// Removed dirs also drop out of the workspaces array, else the manifest lists a missing dir.
pruneRemovedWorkspaces(packageJson, removedDirs)

// The husky tooling (prepare/commitlint scripts + husky/lint-staged/commitlint deps) only leaves
// with the pre-commit feature.
if (precommitRemoved) {
Expand Down