diff --git a/docs/architecture/abstractions.md b/docs/architecture/abstractions.md index fd5d85d..e55dadc 100644 --- a/docs/architecture/abstractions.md +++ b/docs/architecture/abstractions.md @@ -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 --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: ` install`. `default`/`custom` with packages to remove: ` remove` (pnpm) or ` uninstall` (npm) + ` run postinstall`; with nothing to remove: ` 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`) diff --git a/package.json b/package.json index 6d55ec1..084083a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/source/__tests__/operations/cleanupFiles.test.ts b/source/__tests__/operations/cleanupFiles.test.ts index e12e18d..d121dde 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 type { FeatureName } from '../../constants/config.js' +import { type FeatureName, getStackConfig } from '../../constants/config.js' vi.mock('node:fs/promises', () => ({ rm: vi.fn().mockResolvedValue(undefined), @@ -58,6 +58,30 @@ function getWrittenPackageJson(): Record { return JSON.parse(lastCall[1] as string) } +// Reads the workspaces field in either form (string[] or { packages }). +function getWorkspacePackages(pkg: Record): 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({ @@ -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, }), ) } @@ -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') @@ -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', () => { @@ -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', () => { diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index 97d5750..96fb4fa 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -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[], @@ -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) {