From e5a6c97235b011edc5df2085703517381cbb49c2 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:22:59 -0300 Subject: [PATCH 01/15] feat!: remove the canton stack Canton now has its own installer, canton-create in the canton-dappbooster repo. This installer goes back to scaffolding dAppBooster for EVM only. - Removed the canton stack config, its post-install screen and the stack selection step - Removed the --canton, --evm and --stack flags - --info prints { features, modes } again, with no stacks key - The success JSON no longer has a stack field - The override env vars are now DAPPBOOSTER_REPO_URL and DAPPBOOSTER_REF - The review step and the title no longer show the stack - EvmPostInstall is now PostInstall - Removed what only Canton used: the Node version check, the initial commit, the stack-level post-install lines, and the prepare step's scripts and devDependencies BREAKING CHANGE: the stack flags are gone, and --info and the success JSON no longer carry stack data. --- source/__tests__/config.test.ts | 38 +---- source/__tests__/info.test.ts | 131 ++++----------- source/__tests__/nonInteractive.test.ts | 99 ++--------- .../__tests__/operations/cleanupFiles.test.ts | 155 +----------------- source/__tests__/operations/cloneRepo.test.ts | 71 +------- .../operations/createEnvFile.test.ts | 15 +- .../operations/createInitialCommit.test.ts | 59 ------- .../operations/installPackages.test.ts | 30 +--- source/__tests__/utils.test.ts | 64 +------- source/app.tsx | 133 +++++---------- source/cli.tsx | 113 +++---------- source/components/MainTitle.tsx | 42 +---- source/components/steps/CantonPostInstall.tsx | 52 ------ source/components/steps/EvmPostInstall.tsx | 98 ----------- source/components/steps/Install/Install.tsx | 9 +- source/components/steps/PostInstall.tsx | 149 ++++++++++------- source/components/steps/StackSelection.tsx | 65 -------- source/info.ts | 85 ++-------- source/nonInteractive.ts | 53 +----- source/operations/cleanupFiles.ts | 22 +-- source/operations/cloneRepo.ts | 17 +- source/operations/createInitialCommit.ts | 15 -- source/operations/index.ts | 1 - source/stacks/canton.ts | 56 ------- source/stacks/evm.ts | 3 - source/stacks/index.ts | 32 +--- source/types/types.ts | 44 +---- source/utils/utils.ts | 31 +--- 28 files changed, 275 insertions(+), 1407 deletions(-) delete mode 100644 source/__tests__/operations/createInitialCommit.test.ts delete mode 100644 source/components/steps/CantonPostInstall.tsx delete mode 100644 source/components/steps/EvmPostInstall.tsx delete mode 100644 source/components/steps/StackSelection.tsx delete mode 100644 source/operations/createInitialCommit.ts delete mode 100644 source/stacks/canton.ts diff --git a/source/__tests__/config.test.ts b/source/__tests__/config.test.ts index 50d496f..1911bd9 100644 --- a/source/__tests__/config.test.ts +++ b/source/__tests__/config.test.ts @@ -8,12 +8,10 @@ import { describe('getDefaultFeatureNames', () => { 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(stackDefinitions.evm.features) + .filter(([, def]) => def.default) + .map(([name]) => name) + expect(getDefaultFeatureNames('evm')).toEqual(expected) }) it('is a subset of all feature names', () => { @@ -28,32 +26,4 @@ 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..1fa6404 100644 --- a/source/__tests__/info.test.ts +++ b/source/__tests__/info.test.ts @@ -1,71 +1,41 @@ import { describe, expect, it } from 'vitest' import { getInfoOutput } from '../info.js' -import { - getFeatureEntries, - getInstallationModes, - stackDefinitions, - stackNames, -} from '../stacks/index.js' +import { getFeatureEntries, getFeatureNames } 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(getFeatureNames('evm')) }) 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 getFeatureNames('evm')) { + 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') - } + for (const [name, def] of getFeatureEntries('evm')) { + const feature = output.features[name] + if (def.postInstall) { + expect(feature.postInstall).toEqual(def.postInstall) + } else { + expect(feature).not.toHaveProperty('postInstall') } } }) @@ -73,14 +43,12 @@ describe('getInfoOutput — no filter', () => { 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 getFeatureEntries('evm')) { + const feature = output.features[name] + if (def.requires) { + expect(feature.requires).toEqual(def.requires) + } else { + expect(feature).not.toHaveProperty('requires') } } }) @@ -88,57 +56,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 getFeatureNames('evm')) { + 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..a4fb369 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -4,7 +4,6 @@ import { getFeatureNames } from '../stacks/index.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,8 +19,9 @@ 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') @@ -55,13 +55,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') @@ -110,7 +104,7 @@ describe('nonInteractive — validation', () => { ).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:/) }) @@ -119,7 +113,7 @@ describe('nonInteractive — validation', () => { 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 +135,7 @@ describe('nonInteractive — validation', () => { }) }) -describe('nonInteractive — evm full mode execution', () => { +describe('nonInteractive — full mode execution', () => { beforeEach(() => { vi.clearAllMocks() process.exitCode = undefined @@ -165,7 +159,6 @@ 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 () => { @@ -187,12 +180,11 @@ describe('nonInteractive — evm full mode execution', () => { ) }) - it('outputs success JSON with stack=evm and all evm features', async () => { + 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) @@ -217,87 +209,17 @@ describe('nonInteractive — evm full mode execution', () => { }) }) -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 () => { + it('rejects --mode default', 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.error).toMatch(/'default' is not available/) }) }) @@ -446,7 +368,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..a0c882b 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 { getFeatureNames } from '../../stacks/index.js' vi.mock('node:fs/promises', () => ({ rm: vi.fn().mockResolvedValue(undefined), @@ -13,7 +13,7 @@ vi.mock('../../operations/exec.js', () => ({ })) /** - * Directories in the two templates. Everything else the installer removes is a file, and only + * Directories in the template. Everything else the installer removes is a file, and only * removed directories drive script stripping. */ const TEMPLATE_DIRECTORIES = [ @@ -24,10 +24,6 @@ const TEMPLATE_DIRECTORIES = [ 'docs', 'src/subgraphs', 'src/components/pageComponents/home', - 'canton-connect', - 'canton-dappbooster', - 'canton-theme', - 'kit', ] function isTemplateDirectory(target: string): boolean { @@ -97,42 +93,7 @@ function mockEvmPackageJson() { ) } -/** 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() @@ -418,113 +379,3 @@ describe('cleanupFiles — evm', () => { }) }) }) - -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..6888f9c 100644 --- a/source/__tests__/operations/cloneRepo.test.ts +++ b/source/__tests__/operations/cloneRepo.test.ts @@ -10,26 +10,18 @@ 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 -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 () => { + it('clones with execFile using the repo url and projectName as arg', async () => { await cloneRepo('evm', 'my_app') expect(execFile).toHaveBeenCalledWith('git', [ @@ -88,7 +80,7 @@ describe('cloneRepo — evm (tag-latest)', () => { await cloneRepo('evm', '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,12 +88,6 @@ 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') @@ -121,55 +107,10 @@ describe('cloneRepo — evm (tag-latest)', () => { }) }) -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() - }) -}) - -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(() => { @@ -193,7 +134,7 @@ describe('cloneRepo — tag-latest stack pinned to one ref', () => { await cloneRepo('evm', '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..7bd1d51 100644 --- a/source/__tests__/operations/createEnvFile.test.ts +++ b/source/__tests__/operations/createEnvFile.test.ts @@ -7,7 +7,7 @@ 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() }) @@ -33,16 +33,3 @@ describe('createEnvFile — evm', () => { 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') - }) -}) 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..519dd54 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -12,14 +12,13 @@ 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() @@ -176,30 +175,3 @@ describe('installPackages — evm', () => { }) }) }) - -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..2fb147e 100644 --- a/source/__tests__/utils.test.ts +++ b/source/__tests__/utils.test.ts @@ -4,7 +4,6 @@ import { getFeatureEntries, getFeatureNames, stackDefinitions, - stackNames, } from '../stacks/index.js' import { applyFeatureToggle, @@ -14,7 +13,6 @@ import { getPostInstallMessages, isFeatureSelected, isValidName, - meetsNodeVersion, resolveModeFeatures, resolveSelectedFeatures, } from '../utils/utils.js' @@ -78,7 +76,7 @@ describe('isFeatureSelected', () => { }) }) -describe('getPackagesToRemove — evm', () => { +describe('getPackagesToRemove', () => { it('returns empty when all features selected', () => { const allFeatures = getFeatureNames('evm') expect(getPackagesToRemove('evm', allFeatures)).toEqual([]) @@ -109,35 +107,8 @@ describe('getPackagesToRemove — evm', () => { }) }) -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', () => { + it('returns every message for a scaffold that kept every feature', () => { const result = getPostInstallMessages('evm', getFeatureNames('evm')) const allMessages = getFeatureEntries('evm').flatMap(([, def]) => def.postInstall ?? []) @@ -161,12 +132,6 @@ describe('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)', () => { @@ -177,24 +142,21 @@ describe('resolveSelectedFeatures — evm (no requires)', () => { 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' }, + expect(describeInstallPlan('my_app', 'default', [])).toEqual([ { 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 +164,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' }, @@ -221,16 +182,9 @@ describe('resolveModeFeatures', () => { }) 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([]) + expect(resolveModeFeatures('evm', 'default')).toEqual( + resolveSelectedFeatures('evm', getDefaultFeatureNames('evm')), + ) }) }) diff --git a/source/app.tsx b/source/app.tsx index 9c831c7..87dc712 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -9,17 +9,12 @@ 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 { canShowStep, describeInstallPlan, resolveModeFeatures } from './utils/utils.js' -interface Props { - preselectedStack?: Stack -} +const stack: Stack = 'evm' -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 +22,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,89 +32,57 @@ 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 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]) + }, [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( = ({ preselectedStack }) => { projectName={projectName} key={`file-cleanup-${attempt}`} />, - ) - - orderedSteps.push( = ({ preselectedStack }) => { projectName={projectName} key={`install-${attempt}`} />, - ) - - orderedSteps.push( , - ) - - return orderedSteps - }, [ - finishStep, - onSelectStack, - onSelectSelectedFeatures, - onSelectSetupType, - mode, - features, - projectName, - skipFeatures, - stack, - preselectedStack, - attempt, - planSummary, - restart, - ]) + ], + [ + finishStep, + onSelectSelectedFeatures, + onSelectSetupType, + mode, + 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..d07d920 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -3,74 +3,51 @@ 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 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', }, @@ -96,54 +73,10 @@ const cli = meow( }, ) -/** 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) - -if (stackFlagError) { - reportFailure(stackFlagError) -} else if (cli.flags.info) { - console.log(getInfoOutput(resolvedStack)) +if (cli.flags.info) { + 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 +92,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/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/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/Install/Install.tsx b/source/components/steps/Install/Install.tsx index 67f130a..3e34647 100644 --- a/source/components/steps/Install/Install.tsx +++ b/source/components/steps/Install/Install.tsx @@ -1,7 +1,6 @@ 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 { getProjectFolder } from '../../../utils/utils.js' import StepProgress from '../StepProgress.js' @@ -22,12 +21,6 @@ const Install: FC = ({ stack, mode, features, projectName, onCompletion } 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) - } - completeInstall() }, [stack, projectFolder, mode, features], diff --git a/source/components/steps/PostInstall.tsx b/source/components/steps/PostInstall.tsx index 99e4742..d513058 100644 --- a/source/components/steps/PostInstall.tsx +++ b/source/components/steps/PostInstall.tsx @@ -1,76 +1,107 @@ +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 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: + + + {figures.warning} + {figures.warning} WARNING: You MUST finish the + subgraph's configuration manually {figures.warning} + {figures.warning} + + + Follow these steps: - - Move into the project's folder with cd {projectName} + 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 - {lines.map((line) => ( - - {line} - ))} + + 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/info.ts b/source/info.ts index 8948a9d..cc3dc81 100644 --- a/source/info.ts +++ b/source/info.ts @@ -1,80 +1,25 @@ -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 { getFeatureEntries } from './stacks/index.js' + +export function getInfoOutput(): string { + const features = Object.fromEntries( + getFeatureEntries('evm').map(([name, definition]) => [ + name, + { + description: definition.description, + default: definition.default, + ...(definition.postInstall ? { postInstall: definition.postInstall } : {}), + ...(definition.requires ? { requires: definition.requires } : {}), + }, + ]), + ) 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..4aa48c1 100644 --- a/source/nonInteractive.ts +++ b/source/nonInteractive.ts @@ -1,20 +1,7 @@ import process from 'node:process' -import { - cleanupFiles, - cloneRepo, - createEnvFile, - createInitialCommit, - installPackages, -} from './operations/index.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 { getFeatureNames, getInstallationModes, isFeatureNameValid } from './stacks/index.js' import type { FeatureName, InstallationType, Stack } from './types/types.js' import { getPostInstallMessages, @@ -26,7 +13,6 @@ import { type SuccessResult = { success: true - stack: Stack projectName: string mode: InstallationType features: FeatureName[] @@ -80,17 +66,6 @@ function resolveModeAndFeatures( ): { 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: [] } - } - if (!flags.mode) { fail('Missing required flag: --mode') } @@ -100,9 +75,7 @@ function resolveModeAndFeatures( } if (!modes.includes(flags.mode)) { - fail( - `Invalid mode: '${flags.mode}' is not available for the ${stack} stack. Valid modes: ${modes.join(', ')}`, - ) + fail(`Invalid mode: '${flags.mode}' is not available. Valid modes: ${modes.join(', ')}`) } if (flags.mode !== 'custom') { @@ -132,27 +105,19 @@ function resolveModeAndFeatures( if (invalidFeatures.length > 0) { const validNames = getFeatureNames(stack).join(', ') - fail( - `Unknown features for stack '${stack}': ${invalidFeatures.join(', ')}. Valid features: ${validNames}`, - ) + fail(`Unknown features: ${invalidFeatures.join(', ')}. Valid features: ${validNames}`) } return { mode: 'custom', features: resolveModeFeatures(stack, 'custom', features) } } -function validate(flags: { stack?: string; name?: string; mode?: string; features?: string }): { +function validate(flags: { name?: string; mode?: string; features?: string }): { stack: Stack 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 + const stack = 'evm' if (!flags.name) { fail('Missing required flag: --name') @@ -174,7 +139,6 @@ function validate(flags: { stack?: string; name?: string; mode?: string; feature } export async function runNonInteractive(flags: { - stack?: string name?: string mode?: string features?: string @@ -191,15 +155,10 @@ export async function runNonInteractive(flags: { await createEnvFile(stack, projectFolder) await installPackages(stack, projectFolder, mode, features) - if (getStackConfig(stack).initialCommit) { - await createInitialCommit(projectFolder) - } - completeInstall() const result: SuccessResult = { success: true, - stack, projectName: name, mode, features, diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index cb03018..8d09592 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -7,12 +7,8 @@ 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 } /** @@ -22,7 +18,6 @@ type PackageJson = { */ type CleanupPlan = { scripts: string[] - devDependencies: string[] removedDirs: string[] } @@ -147,13 +142,12 @@ function scriptsRunningRemovedDirs( /** * 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. + * changed. Feature dependencies are left to the package manager, which runs next. */ function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { - const { scripts, devDependencies, removedDirs } = plan + const { scripts, removedDirs } = plan - if (scripts.length === 0 && devDependencies.length === 0 && removedDirs.length === 0) { + if (scripts.length === 0 && removedDirs.length === 0) { return } @@ -172,9 +166,7 @@ function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { ...scriptsRunningRemovedDirs(packageJson.scripts, removedDirs), ]) - const dependenciesRemoved = removeKeys(packageJson.devDependencies, devDependencies) - - if (scriptsRemoved || dependenciesRemoved) { + if (scriptsRemoved) { writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) } } @@ -193,11 +185,7 @@ export async function cleanupFiles( ): Promise { const { prepare, staging } = getStackConfig(stack) - const plan: CleanupPlan = { - scripts: [...(prepare?.scripts ?? [])], - devDependencies: [...(prepare?.devDependencies ?? [])], - removedDirs: [], - } + const plan: CleanupPlan = { scripts: [], removedDirs: [] } if (prepare) { onProgress?.(prepare.label) diff --git a/source/operations/cloneRepo.ts b/source/operations/cloneRepo.ts index 4cb0db8..36ee988 100644 --- a/source/operations/cloneRepo.ts +++ b/source/operations/cloneRepo.ts @@ -1,16 +1,15 @@ 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 { 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. + * 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. */ export async function cloneRepo( stack: Stack, @@ -20,12 +19,6 @@ export async function cloneRepo( const config = getStackConfig(stack) 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']) @@ -37,14 +30,14 @@ export async function cloneRepo( 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}`) + onProgress?.(`Cloning dAppBooster (${config.ref}) in ${projectName}`) await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) onProgress?.(`Checking out ${config.ref}`) await execFile('git', ['fetch', '--depth', '1', 'origin', config.ref], { cwd: projectFolder }) await execFile('git', ['checkout', 'FETCH_HEAD'], { cwd: projectFolder }) } else { - onProgress?.(`Cloning ${config.label} in ${projectName}`) + onProgress?.(`Cloning dAppBooster in ${projectName}`) await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) onProgress?.('Fetching tags') 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/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 index 303ebd7..a3d2ad4 100644 --- a/source/stacks/evm.ts +++ b/source/stacks/evm.ts @@ -1,8 +1,6 @@ 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: { @@ -13,7 +11,6 @@ export const evm = { label: 'Install script', paths: ['.install-files'], }, - postInstallComponent: () => import('../components/steps/EvmPostInstall.js'), envFiles: [{ from: '.env.example', to: '.env.local' }], features: { demo: { diff --git a/source/stacks/index.ts b/source/stacks/index.ts index 0706897..5473b9e 100644 --- a/source/stacks/index.ts +++ b/source/stacks/index.ts @@ -6,23 +6,19 @@ import type { 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 stackDefinitions = { evm } 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] +function envOverride(suffix: 'REPO_URL' | 'REF'): string | undefined { + const value = process.env[`DAPPBOOSTER_${suffix}`] 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 + const repoUrl = envOverride('REPO_URL') ?? base.repoUrl + const ref = envOverride('REF') ?? base.ref return { ...base, repoUrl, ref } } @@ -39,10 +35,6 @@ export function isFeatureNameValid(stack: Stack, name: string): name is FeatureN 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) @@ -50,18 +42,10 @@ export function getDefaultFeatureNames(stack: Stack): FeatureName[] { } /** - * 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`. + * Installation modes a stack offers. `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 + const hasOptOutFeature = getDefaultFeatureNames(stack).length < getFeatureNames(stack).length return hasOptOutFeature ? ['default', 'full', 'custom'] : ['full', 'custom'] } diff --git a/source/types/types.ts b/source/types/types.ts index 2d290f6..c0e0b43 100644 --- a/source/types/types.ts +++ b/source/types/types.ts @@ -1,13 +1,8 @@ -import type { FC } from 'react' - -export type Stack = 'evm' | 'canton' +export type Stack = 'evm' 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 EVM stack defines. `stacks/evm.ts` fails to compile if they disagree. */ export type FeatureName = 'demo' | 'subgraph' | 'typedoc' | 'vocs' | 'husky' export type InstallationType = 'full' | 'default' | 'custom' @@ -52,49 +47,22 @@ export type CleanupGroup = { 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. + * only `DAPPBOOSTER_REF` sets it, for forks and pre-release testing. + * @property prepare - What every scaffold drops, whatever the user picked: paths the template + * keeps for itself. Applied before the package manager runs. * @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 }> + prepare?: CleanupGroup staging?: CleanupGroup - initialCommit?: boolean envFiles: EnvFile[] features: Record } diff --git a/source/utils/utils.ts b/source/utils/utils.ts index 32ee816..036f17a 100644 --- a/source/utils/utils.ts +++ b/source/utils/utils.ts @@ -31,22 +31,6 @@ 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`. */ @@ -127,18 +111,13 @@ export const MODE_LABELS: Record = { 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] }, ] @@ -157,14 +136,10 @@ export function getPackagesToRemove(stack: Stack, selectedFeatures: FeatureName[ .flatMap(([, definition]) => definition.packages) } -/** Post-install guidance for a scaffold: the stack's own, then that of each feature it kept. */ +/** Post-install guidance for a scaffold: 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 ?? []), - ] + return features.flatMap((name) => config.features[name]?.postInstall ?? []) } /** From 056b3cc784d0349313e4b9b021a507fb55071ea3 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:26:17 -0300 Subject: [PATCH 02/15] refactor: remove code only the canton stack used These code paths existed for Canton features and the Canton template. The EVM stack never reaches them. - Removed the default installation mode. It needs a feature that is off by default, and every EVM feature is on by default, so EVM never offered it. --mode default now fails like any other unknown mode - Removed the feature requires field, the toggle logic that enforced it, and the transformSelection prop it added to MultiSelect - installPackages runs the postinstall script again without checking that it exists. dAppBooster always ships one - cleanupFiles no longer strips scripts that run a removed directory. Each EVM feature already lists its own scripts --- source/__tests__/config.test.ts | 13 +-- source/__tests__/info.test.ts | 13 --- source/__tests__/nonInteractive.test.ts | 2 +- .../__tests__/operations/cleanupFiles.test.ts | 19 ---- .../operations/installPackages.test.ts | 8 -- source/__tests__/utils.test.ts | 44 +-------- source/app.tsx | 3 +- source/components/Multiselect/MultiSelect.tsx | 29 +----- source/components/steps/InstallationMode.tsx | 14 ++- source/components/steps/OptionalPackages.tsx | 34 ++----- source/info.ts | 1 - source/nonInteractive.ts | 16 +-- source/operations/cleanupFiles.ts | 92 +++++------------- source/operations/installPackages.ts | 18 +--- source/stacks/index.ts | 17 +--- source/types/types.ts | 6 +- source/utils/utils.ts | 97 +------------------ 17 files changed, 60 insertions(+), 366 deletions(-) diff --git a/source/__tests__/config.test.ts b/source/__tests__/config.test.ts index 1911bd9..fa98f58 100644 --- a/source/__tests__/config.test.ts +++ b/source/__tests__/config.test.ts @@ -1,10 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - getDefaultFeatureNames, - getFeatureNames, - getInstallationModes, - stackDefinitions, -} from '../stacks/index.js' +import { getDefaultFeatureNames, getFeatureNames, stackDefinitions } from '../stacks/index.js' describe('getDefaultFeatureNames', () => { it('returns only features whose default flag is true, in config order', () => { @@ -21,9 +16,3 @@ describe('getDefaultFeatureNames', () => { } }) }) - -describe('getInstallationModes', () => { - it('offers full/custom for evm, where every feature is on by default', () => { - expect(getInstallationModes('evm')).toEqual(['full', 'custom']) - }) -}) diff --git a/source/__tests__/info.test.ts b/source/__tests__/info.test.ts index 1fa6404..53fa80b 100644 --- a/source/__tests__/info.test.ts +++ b/source/__tests__/info.test.ts @@ -40,19 +40,6 @@ describe('getInfoOutput', () => { } }) - it('surfaces requires only for features that declare a dependency', () => { - const output = JSON.parse(getInfoOutput()) - - for (const [name, def] of getFeatureEntries('evm')) { - const feature = output.features[name] - if (def.requires) { - expect(feature.requires).toEqual(def.requires) - } else { - expect(feature).not.toHaveProperty('requires') - } - } - }) - it('does not leak label, packages, or paths into feature output', () => { const output = JSON.parse(getInfoOutput()) diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index a4fb369..0ab46ef 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -219,7 +219,7 @@ describe('nonInteractive — default mode', () => { 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/) + expect(output.error).toBe("Invalid mode: must be 'full' or 'custom'") }) }) diff --git a/source/__tests__/operations/cleanupFiles.test.ts b/source/__tests__/operations/cleanupFiles.test.ts index a0c882b..3f3e54e 100644 --- a/source/__tests__/operations/cleanupFiles.test.ts +++ b/source/__tests__/operations/cleanupFiles.test.ts @@ -12,28 +12,9 @@ vi.mock('../../operations/exec.js', () => ({ execFile: vi.fn().mockResolvedValue(undefined), })) -/** - * Directories in the template. 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', -] - -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') diff --git a/source/__tests__/operations/installPackages.test.ts b/source/__tests__/operations/installPackages.test.ts index 519dd54..2639605 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -6,22 +6,14 @@ vi.mock('../../operations/exec.js', () => ({ 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') -function mockPackageJson(scripts: Record = { postinstall: 'wagmi generate' }) { - vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ scripts })) -} - const evmFeatures = stackDefinitions.evm.features describe('installPackages', () => { beforeEach(() => { vi.clearAllMocks() - mockPackageJson() }) describe('full mode', () => { diff --git a/source/__tests__/utils.test.ts b/source/__tests__/utils.test.ts index 2fb147e..7108ea5 100644 --- a/source/__tests__/utils.test.ts +++ b/source/__tests__/utils.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from 'vitest' +import { getFeatureEntries, getFeatureNames, stackDefinitions } from '../stacks/index.js' import { - getDefaultFeatureNames, - getFeatureEntries, - getFeatureNames, - stackDefinitions, -} from '../stacks/index.js' -import { - applyFeatureToggle, deriveStepDisplay, describeInstallPlan, getPackagesToRemove, @@ -14,7 +8,6 @@ import { isFeatureSelected, isValidName, resolveModeFeatures, - resolveSelectedFeatures, } from '../utils/utils.js' const evmFeatures = stackDefinitions.evm.features @@ -134,12 +127,6 @@ describe('getPostInstallMessages', () => { }) }) -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('my_app', 'full', [])).toEqual([ @@ -148,13 +135,6 @@ describe('describeInstallPlan', () => { ]) }) - it('names the default mode as the selector did', () => { - expect(describeInstallPlan('my_app', 'default', [])).toEqual([ - { label: 'Project', value: 'my_app' }, - { label: 'Mode', value: 'Default (recommended)' }, - ]) - }) - it('lists the selected features for a custom-mode plan', () => { expect(describeInstallPlan('my_app', 'custom', ['demo', 'subgraph'])).toEqual([ { label: 'Project', value: 'my_app' }, @@ -177,26 +157,8 @@ describe('resolveModeFeatures', () => { 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', () => { - expect(resolveModeFeatures('evm', 'default')).toEqual( - resolveSelectedFeatures('evm', getDefaultFeatureNames('evm')), - ) - }) -}) - -describe('applyFeatureToggle — evm (no dependencies)', () => { - it('selecting a feature adds it in config order', () => { - expect(applyFeatureToggle('evm', ['subgraph'], 'demo', 'select')).toEqual(['demo', 'subgraph']) - }) - - 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('evm', 'custom', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph']) }) }) diff --git a/source/app.tsx b/source/app.tsx index 87dc712..719c461 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -36,7 +36,7 @@ const App: FC = () => { setAttempt((prev) => prev + 1) }, []) - const skipFeatures = setupType?.value === 'full' || setupType?.value === 'default' + const skipFeatures = setupType?.value === 'full' const mode = setupType?.value ?? 'full' @@ -59,7 +59,6 @@ const App: FC = () => { key={`project-name-${attempt}`} />, = { 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,26 +59,15 @@ 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(() => { diff --git a/source/components/steps/InstallationMode.tsx b/source/components/steps/InstallationMode.tsx index 9a8da24..d3f73ec 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 } 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 = (['full', 'custom'] as const).map( + (value) => ({ label: MODE_LABELS[value], value }), +) - 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..e94fcc6 100644 --- a/source/components/steps/OptionalPackages.tsx +++ b/source/components/steps/OptionalPackages.tsx @@ -1,8 +1,7 @@ import { Text } from 'ink' -import { type FC, useCallback, useEffect, useMemo, useState } from 'react' -import { getFeatureEntries } from '../../stacks/index.js' +import { type FC, useEffect, useMemo, useState } from 'react' +import { getDefaultFeatureNames, getFeatureEntries } from '../../stacks/index.js' import type { MultiSelectItem, Stack } from '../../types/types.js' -import { applyFeatureToggle, resolveModeFeatures } from '../../utils/utils.js' import MultiSelect from '../Multiselect/index.js' interface Props { @@ -25,29 +24,10 @@ const OptionalPackages: FC = ({ stack, onCompletion, onSubmit, skip = fal ) const defaultSelected: Array = useMemo(() => { - const defaults = resolveModeFeatures(stack, 'default') + const defaults = getDefaultFeatureNames(stack) 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) { @@ -56,8 +36,11 @@ const OptionalPackages: FC = ({ stack, onCompletion, onSubmit, skip = fal }, []) const onHandleSubmit = (selectedItems: Array) => { - onSubmit(selectedItems) - setSubmitted(selectedItems) + const inConfigOrder = customPackages.filter((pkg) => + selectedItems.some((item) => item.value === pkg.value), + ) + onSubmit(inConfigOrder) + setSubmitted(inConfigOrder) onCompletion() } @@ -87,7 +70,6 @@ const OptionalPackages: FC = ({ stack, onCompletion, onSubmit, skip = fal focus items={customPackages} onSubmit={onHandleSubmit} - transformSelection={transformSelection} /> ) diff --git a/source/info.ts b/source/info.ts index cc3dc81..eca6381 100644 --- a/source/info.ts +++ b/source/info.ts @@ -8,7 +8,6 @@ export function getInfoOutput(): string { description: definition.description, default: definition.default, ...(definition.postInstall ? { postInstall: definition.postInstall } : {}), - ...(definition.requires ? { requires: definition.requires } : {}), }, ]), ) diff --git a/source/nonInteractive.ts b/source/nonInteractive.ts index 4aa48c1..2faf308 100644 --- a/source/nonInteractive.ts +++ b/source/nonInteractive.ts @@ -1,7 +1,7 @@ import process from 'node:process' import { cleanupFiles, cloneRepo, createEnvFile, installPackages } from './operations/index.js' import { beginInstall, completeInstall } from './operations/installGuard.js' -import { getFeatureNames, getInstallationModes, isFeatureNameValid } from './stacks/index.js' +import { getFeatureNames, isFeatureNameValid } from './stacks/index.js' import type { FeatureName, InstallationType, Stack } from './types/types.js' import { getPostInstallMessages, @@ -64,22 +64,16 @@ function resolveModeAndFeatures( stack: Stack, flags: { mode?: string; features?: string }, ): { mode: InstallationType; features: FeatureName[] } { - const modes = getInstallationModes(stack) - 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. 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(stack, 'full') } } if (!flags.features) { diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index 8d09592..635cbff 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -1,4 +1,4 @@ -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' @@ -11,62 +11,44 @@ type PackageJson = { scripts?: Record } -/** - * 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[] - 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. `full` mode keeps everything. */ async function removeDeselectedFeatures( stack: Stack, projectFolder: string, mode: InstallationType, features: FeatureName[], - plan: CleanupPlan, onProgress?: (step: string) => void, -): Promise { +): Promise { + const scripts: string[] = [] + if (mode === 'full') { - return + return scripts } for (const [name, definition] of getFeatureEntries(stack)) { - const { paths = [], scripts = [] } = definition + 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 { @@ -120,34 +102,11 @@ function removeKeys(block: Record | undefined, keys: string[]): } /** - * 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. - */ -function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { - const { scripts, removedDirs } = plan - - if (scripts.length === 0 && removedDirs.length === 0) { +function patchPackageJson(projectFolder: string, scripts: string[]): void { + if (scripts.length === 0) { return } @@ -161,12 +120,7 @@ function patchPackageJson(projectFolder: string, plan: CleanupPlan): void { return } - const scriptsRemoved = removeKeys(packageJson.scripts, [ - ...scripts, - ...scriptsRunningRemovedDirs(packageJson.scripts, removedDirs), - ]) - - if (scriptsRemoved) { + if (removeKeys(packageJson.scripts, scripts)) { writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) } } @@ -185,21 +139,19 @@ export async function cleanupFiles( ): Promise { const { prepare, staging } = getStackConfig(stack) - const plan: CleanupPlan = { scripts: [], removedDirs: [] } - if (prepare) { onProgress?.(prepare.label) - plan.removedDirs.push(...(await removePaths(projectFolder, prepare.paths))) + await removePaths(projectFolder, prepare.paths) } - await removeDeselectedFeatures(stack, projectFolder, mode, features, plan, onProgress) + const scripts = await removeDeselectedFeatures(stack, projectFolder, mode, features, onProgress) // The staged replacements only exist in stacks that ship a staging directory. if (staging && mode !== 'full') { await restoreStagedHomePage(projectFolder, features) } - patchPackageJson(projectFolder, plan) + patchPackageJson(projectFolder, scripts) if (staging) { onProgress?.(staging.label) diff --git a/source/operations/installPackages.ts b/source/operations/installPackages.ts index 2b2bbfa..b658c6f 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -1,20 +1,8 @@ -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 { 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. @@ -38,8 +26,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', 'postinstall'], { cwd: projectFolder }) } diff --git a/source/stacks/index.ts b/source/stacks/index.ts index 5473b9e..bd82de6 100644 --- a/source/stacks/index.ts +++ b/source/stacks/index.ts @@ -1,11 +1,5 @@ import process from 'node:process' -import type { - FeatureDefinition, - FeatureName, - InstallationType, - Stack, - StackConfig, -} from '../types/types.js' +import type { FeatureDefinition, FeatureName, Stack, StackConfig } from '../types/types.js' import { evm } from './evm.js' export const stackDefinitions = { evm } satisfies Record @@ -40,12 +34,3 @@ export function getDefaultFeatureNames(stack: Stack): FeatureName[] { .filter(([, definition]) => definition.default) .map(([name]) => name) } - -/** - * Installation modes a stack offers. `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 hasOptOutFeature = getDefaultFeatureNames(stack).length < getFeatureNames(stack).length - return hasOptOutFeature ? ['default', 'full', 'custom'] : ['full', 'custom'] -} diff --git a/source/types/types.ts b/source/types/types.ts index c0e0b43..0fe393b 100644 --- a/source/types/types.ts +++ b/source/types/types.ts @@ -5,7 +5,7 @@ export type PackageManager = 'pnpm' /** Every feature name the EVM stack defines. `stacks/evm.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,10 +20,7 @@ export type PlanSummaryItem = { label: string; value: string } * @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 @@ -33,7 +30,6 @@ export type FeatureDefinition = { postInstall?: string[] paths?: string[] scripts?: string[] - requires?: FeatureName[] } export type EnvFile = { diff --git a/source/utils/utils.ts b/source/utils/utils.ts index 036f17a..fccd435 100644 --- a/source/utils/utils.ts +++ b/source/utils/utils.ts @@ -1,12 +1,7 @@ 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 { getFeatureEntries, getFeatureNames, getStackConfig } from '../stacks/index.js' import type { FeatureName, InstallationType, PlanSummaryItem, Stack } from '../types/types.js' export function getProjectFolder(projectName: string) { @@ -31,82 +26,8 @@ export function isFeatureSelected(feature: FeatureName, selectedFeatures: Featur return selectedFeatures.includes(feature) } -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', } @@ -143,24 +64,16 @@ export function getPostInstallMessages(stack: Stack, features: FeatureName[]): s } /** - * 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) + const names = getFeatureNames(stack) + return mode === 'full' ? names : names.filter((name) => customSelection.includes(name)) } export function projectDirectoryExists(projectName: string): boolean { From 5b5692ea58524d595c696655cdb37e330cd21f08 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:29:14 -0300 Subject: [PATCH 03/15] refactor: drop the stack layer With one template left, the stack layer only added indirection. Nothing changes for users. - source/stacks/ is now source/config.ts. It holds the feature map, the prepare and staging paths, and getRepo(), which reads the DAPPBOOSTER_REPO_URL and DAPPBOOSTER_REF overrides - Operations, helpers and step components no longer take a stack argument - Removed the Stack, StackConfig, PackageManager and EnvFile types - pnpm and the .env.example to .env.local copy are now written where they are used --- source/__tests__/config.test.ts | 12 +-- source/__tests__/info.test.ts | 10 +- source/__tests__/nonInteractive.test.ts | 34 +++--- .../__tests__/operations/cleanupFiles.test.ts | 92 ++++++---------- source/__tests__/operations/cloneRepo.test.ts | 31 +++--- .../operations/createEnvFile.test.ts | 6 +- .../operations/installPackages.test.ts | 50 ++++----- source/__tests__/utils.test.ts | 38 ++++--- source/app.tsx | 10 +- .../components/steps/CloneRepo/CloneRepo.tsx | 8 +- source/components/steps/FileCleanup.tsx | 10 +- source/components/steps/Install/Install.tsx | 11 +- source/components/steps/OptionalPackages.tsx | 30 ++---- source/config.ts | 102 ++++++++++++++++++ source/info.ts | 4 +- source/nonInteractive.ts | 41 ++++--- source/operations/cleanupFiles.ts | 27 ++--- source/operations/cloneRepo.ts | 32 +++--- source/operations/createEnvFile.ts | 8 +- source/operations/installPackages.ts | 13 +-- source/stacks/evm.ts | 72 ------------- source/stacks/index.ts | 36 ------- source/types/types.ts | 33 +----- source/utils/utils.ts | 19 ++-- 24 files changed, 302 insertions(+), 427 deletions(-) create mode 100644 source/config.ts delete mode 100644 source/stacks/evm.ts delete mode 100644 source/stacks/index.ts diff --git a/source/__tests__/config.test.ts b/source/__tests__/config.test.ts index fa98f58..ccb70c9 100644 --- a/source/__tests__/config.test.ts +++ b/source/__tests__/config.test.ts @@ -1,17 +1,17 @@ import { describe, expect, it } from 'vitest' -import { getDefaultFeatureNames, getFeatureNames, 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', () => { - const expected = Object.entries(stackDefinitions.evm.features) + const expected = Object.entries(featureDefinitions) .filter(([, def]) => def.default) .map(([name]) => name) - expect(getDefaultFeatureNames('evm')).toEqual(expected) + 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) } }) diff --git a/source/__tests__/info.test.ts b/source/__tests__/info.test.ts index 53fa80b..95a4371 100644 --- a/source/__tests__/info.test.ts +++ b/source/__tests__/info.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' +import { featureEntries, featureNames } from '../config.js' import { getInfoOutput } from '../info.js' -import { getFeatureEntries, getFeatureNames } from '../stacks/index.js' describe('getInfoOutput', () => { it('returns valid JSON', () => { @@ -14,13 +14,13 @@ describe('getInfoOutput', () => { it('lists every feature', () => { const output = JSON.parse(getInfoOutput()) - expect(Object.keys(output.features)).toEqual(getFeatureNames('evm')) + expect(Object.keys(output.features)).toEqual(featureNames) }) it('each feature has description and default', () => { const output = JSON.parse(getInfoOutput()) - for (const name of getFeatureNames('evm')) { + for (const name of featureNames) { const feature = output.features[name] expect(typeof feature.description).toBe('string') expect(typeof feature.default).toBe('boolean') @@ -30,7 +30,7 @@ describe('getInfoOutput', () => { it('includes postInstall only for features that declare it', () => { const output = JSON.parse(getInfoOutput()) - for (const [name, def] of getFeatureEntries('evm')) { + for (const [name, def] of featureEntries) { const feature = output.features[name] if (def.postInstall) { expect(feature.postInstall).toEqual(def.postInstall) @@ -43,7 +43,7 @@ describe('getInfoOutput', () => { it('does not leak label, packages, or paths into feature output', () => { const output = JSON.parse(getInfoOutput()) - for (const name of getFeatureNames('evm')) { + for (const name of featureNames) { const feature = output.features[name] expect(feature).not.toHaveProperty('label') expect(feature).not.toHaveProperty('packages') diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index 0ab46ef..6e18ab5 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -1,5 +1,5 @@ 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), @@ -24,8 +24,6 @@ const { cloneRepo, createEnvFile, installPackages, cleanupFiles } = await import ) const { projectDirectoryExists } = await import('../utils/utils.js') -const evmFeatureNames = getFeatureNames('evm') - function getLastJsonOutput(): Record { const lastCall = mockLog.mock.calls.at(-1) if (!lastCall) { @@ -98,7 +96,7 @@ 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() @@ -161,22 +159,20 @@ describe('nonInteractive — full mode execution', () => { expect(callOrder).toEqual(['cloneRepo', 'cleanupFiles', 'createEnvFile', 'installPackages']) }) - 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(cloneRepo).toHaveBeenCalledWith('my_app') + expect(createEnvFile).toHaveBeenCalledWith(expect.stringContaining('my_app')) expect(installPackages).toHaveBeenCalledWith( - 'evm', expect.stringContaining('my_app'), 'full', - evmFeatureNames, + featureNames, ) expect(cleanupFiles).toHaveBeenCalledWith( - 'evm', expect.stringContaining('my_app'), 'full', - evmFeatureNames, + featureNames, ) }) @@ -187,7 +183,7 @@ describe('nonInteractive — full mode execution', () => { expect(output.success).toBe(true) 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) }) @@ -205,7 +201,7 @@ describe('nonInteractive — full mode execution', () => { await runNonInteractive({ name: 'my_app', mode: 'full', features: 'demo' }) const output = getLastJsonOutput() - expect(output.features).toEqual(evmFeatureNames) + expect(output.features).toEqual(featureNames) }) }) @@ -232,13 +228,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'), 'custom', [ + 'demo', + 'subgraph', + ]) + expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), 'custom', [ 'demo', 'subgraph', ]) diff --git a/source/__tests__/operations/cleanupFiles.test.ts b/source/__tests__/operations/cleanupFiles.test.ts index 3f3e54e..5a8686c 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 } from '../../stacks/index.js' +import { featureNames } from '../../config.js' vi.mock('node:fs/promises', () => ({ rm: vi.fn().mockResolvedValue(undefined), @@ -45,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', @@ -55,7 +53,7 @@ const EVM_DEV_DEPS = { vocs: '^1.0.0', } -function mockEvmPackageJson() { +function mockPackageJson() { vi.mocked(readFileSync).mockReturnValue( JSON.stringify({ scripts: { @@ -69,7 +67,7 @@ function mockEvmPackageJson() { prepare: 'husky install', commitlint: 'commitlint --edit', }, - devDependencies: EVM_DEV_DEPS, + devDependencies: DEV_DEPS, }), ) } @@ -77,12 +75,12 @@ function mockEvmPackageJson() { describe('cleanupFiles', () => { beforeEach(() => { vi.clearAllMocks() - mockEvmPackageJson() + mockPackageJson() }) describe('full mode', () => { it('removes repository metadata and .install-files', async () => { - await cleanupFiles('evm', '/project/my_app', 'full') + await cleanupFiles('/project/my_app', 'full') const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', '.install-files')) @@ -91,7 +89,7 @@ describe('cleanupFiles', () => { }) it('keeps every feature, so package.json is left untouched', async () => { - await cleanupFiles('evm', '/project/my_app', 'full') + await cleanupFiles('/project/my_app', 'full') expect(getRmPaths()).not.toContain(resolve('/project/my_app', '.husky')) expect(writeFileSync).not.toHaveBeenCalled() @@ -101,7 +99,7 @@ describe('cleanupFiles', () => { 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) + await cleanupFiles('/project/my_app', 'custom', featureNames) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', '.install-files')) @@ -109,7 +107,7 @@ describe('cleanupFiles', () => { }) it('leaves package.json alone when there is nothing to strip', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ALL_EVM_FEATURES) + await cleanupFiles('/project/my_app', 'custom', featureNames) expect(writeFileSync).not.toHaveBeenCalled() }) @@ -117,12 +115,7 @@ describe('cleanupFiles', () => { describe('custom mode — 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', 'custom', ['subgraph', 'typedoc', 'vocs', 'husky']) const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home') expect(getRmPaths()).toContain(homeFolder) @@ -138,13 +131,13 @@ describe('cleanupFiles', () => { describe('custom mode — subgraph deselected', () => { it('removes src/subgraphs', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', 'custom', ['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', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) const homeFolder = resolve('/project/my_app', 'src/components/pageComponents/home') expect(getRmPaths()).toContain(resolve(homeFolder, 'Examples/demos/subgraphs')) @@ -158,7 +151,7 @@ describe('cleanupFiles', () => { }) 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', 'custom', ['typedoc', 'vocs', 'husky']) const subgraphDemosPath = resolve( '/project/my_app', @@ -168,7 +161,7 @@ describe('cleanupFiles', () => { }) 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', 'custom', ['demo', 'typedoc', 'vocs', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -178,13 +171,13 @@ describe('cleanupFiles', () => { describe('custom mode — typedoc deselected', () => { it('removes typedoc.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'subgraph', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', 'custom', ['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', 'custom', ['demo', 'subgraph', 'vocs', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -194,24 +187,14 @@ describe('cleanupFiles', () => { describe('custom mode — 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', 'custom', ['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', 'custom', ['demo', 'subgraph', 'typedoc', 'husky']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -223,12 +206,7 @@ describe('cleanupFiles', () => { describe('custom mode — 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', 'custom', ['demo', 'subgraph', 'typedoc', 'vocs']) expect(getRmPaths()).toContain(resolve('/project/my_app', '.husky')) expect(getRmPaths()).toContain(resolve('/project/my_app', '.lintstagedrc.mjs')) @@ -236,12 +214,7 @@ describe('cleanupFiles', () => { }) 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', 'custom', ['demo', 'subgraph', 'typedoc', 'vocs']) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -250,12 +223,7 @@ describe('cleanupFiles', () => { }) 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', 'custom', ['demo', 'subgraph', 'typedoc', 'vocs']) const devDeps = getWrittenPackageJson().devDependencies as Record expect(devDeps.husky).toBe('^9.1.7') @@ -265,7 +233,7 @@ describe('cleanupFiles', () => { describe('custom mode — 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', 'custom', featureNames) const paths = getRmPaths() expect(paths).not.toContain(resolve('/project/my_app', '.husky')) @@ -277,7 +245,7 @@ describe('cleanupFiles', () => { describe('custom mode — no features selected', () => { it('runs all cleanup operations', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', 'custom', []) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', 'src/components/pageComponents/home')) @@ -289,7 +257,7 @@ describe('cleanupFiles', () => { }) it('removes all optional scripts from package.json', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', 'custom', []) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -305,14 +273,14 @@ describe('cleanupFiles', () => { }) it('always removes .install-files as the last rm call', async () => { - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo']) + await cleanupFiles('/project/my_app', 'custom', ['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', 'custom', []) for (const call of vi.mocked(rm).mock.calls) { const options = call[1] as { force?: boolean } @@ -323,14 +291,14 @@ describe('cleanupFiles', () => { describe('onProgress callback', () => { it('reports the prepare and install-script steps for full mode', async () => { const steps: string[] = [] - await cleanupFiles('evm', '/project/my_app', 'full', [], (step) => steps.push(step)) + await cleanupFiles('/project/my_app', 'full', [], (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', 'custom', [], (step) => steps.push(step)) expect(steps).toEqual([ 'Repository metadata', @@ -345,7 +313,7 @@ describe('cleanupFiles', () => { it('skips steps for selected features', async () => { const steps: string[] = [] - await cleanupFiles('evm', '/project/my_app', 'custom', ['demo', 'subgraph'], (step) => + await cleanupFiles('/project/my_app', 'custom', ['demo', 'subgraph'], (step) => steps.push(step), ) @@ -356,7 +324,7 @@ describe('cleanupFiles', () => { }) it('works without a callback', async () => { - await expect(cleanupFiles('evm', '/project/my_app', 'full')).resolves.toBeUndefined() + await expect(cleanupFiles('/project/my_app', 'full')).resolves.toBeUndefined() }) }) }) diff --git a/source/__tests__/operations/cloneRepo.test.ts b/source/__tests__/operations/cloneRepo.test.ts index 6888f9c..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), @@ -14,7 +13,7 @@ const { exec, execFile } = await import('../../operations/exec.js') const { rm } = await import('node:fs/promises') const { cloneRepo } = await import('../../operations/cloneRepo.js') -const evmRepoUrl = stackDefinitions.evm.repoUrl +const repoUrl = 'https://github.com/BootNodeDev/dAppBooster.git' describe('cloneRepo — latest tag', () => { beforeEach(() => { @@ -22,20 +21,20 @@ describe('cloneRepo — latest tag', () => { }) it('clones with execFile using the repo url and projectName as arg', async () => { - await cloneRepo('evm', 'my_app') + 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'), @@ -43,7 +42,7 @@ describe('cloneRepo — latest tag', () => { }) 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'), @@ -51,7 +50,7 @@ describe('cloneRepo — latest tag', () => { }) 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, @@ -60,7 +59,7 @@ describe('cloneRepo — latest tag', () => { }) 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'), @@ -68,7 +67,7 @@ describe('cloneRepo — latest tag', () => { }) 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') @@ -77,7 +76,7 @@ describe('cloneRepo — latest tag', () => { 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 dAppBooster in my_app', @@ -89,21 +88,21 @@ describe('cloneRepo — latest tag', () => { }) 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() + await expect(cloneRepo('my_app')).resolves.toBeUndefined() }) }) @@ -118,7 +117,7 @@ describe('cloneRepo — 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'), @@ -131,7 +130,7 @@ describe('cloneRepo — 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 dAppBooster (v1.2.3) in my_app', diff --git a/source/__tests__/operations/createEnvFile.test.ts b/source/__tests__/operations/createEnvFile.test.ts index 7bd1d51..01b4765 100644 --- a/source/__tests__/operations/createEnvFile.test.ts +++ b/source/__tests__/operations/createEnvFile.test.ts @@ -13,7 +13,7 @@ describe('createEnvFile', () => { }) 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', () => { }) 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,6 +30,6 @@ describe('createEnvFile', () => { 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') + await expect(createEnvFile('/project/my_app')).rejects.toThrow('.env.example not found') }) }) diff --git a/source/__tests__/operations/installPackages.test.ts b/source/__tests__/operations/installPackages.test.ts index 2639605..28230c9 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -1,5 +1,5 @@ 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), @@ -9,8 +9,6 @@ vi.mock('../../operations/exec.js', () => ({ const { execFile } = await import('../../operations/exec.js') const { installPackages } = await import('../../operations/installPackages.js') -const evmFeatures = stackDefinitions.evm.features - describe('installPackages', () => { beforeEach(() => { vi.clearAllMocks() @@ -18,19 +16,19 @@ describe('installPackages', () => { describe('full mode', () => { it('runs pnpm install via execFile', async () => { - await installPackages('evm', '/project/my_app', 'full') + await installPackages('/project/my_app', 'full') 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', 'full') expect(execFile).toHaveBeenCalledTimes(1) }) it('ignores features argument', async () => { - await installPackages('evm', '/project/my_app', 'full', ['demo', 'subgraph']) + await installPackages('/project/my_app', 'full', ['demo', 'subgraph']) expect(execFile).toHaveBeenCalledTimes(1) expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) @@ -39,8 +37,8 @@ describe('installPackages', () => { 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) + const allFeatures = featureNames + await installPackages('/project/my_app', 'custom', allFeatures) expect(execFile).toHaveBeenCalledTimes(1) expect(execFile).toHaveBeenCalledWith('pnpm', ['install'], { cwd: '/project/my_app' }) @@ -49,7 +47,7 @@ describe('installPackages', () => { describe('custom mode — 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', 'custom', ['demo']) const removeCall = vi .mocked(execFile) @@ -57,10 +55,10 @@ describe('installPackages', () => { 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) } }) @@ -76,13 +74,13 @@ describe('installPackages', () => { } }) - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', 'custom', ['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', 'custom', ['demo', 'subgraph']) const removeCall = vi .mocked(execFile) @@ -90,13 +88,13 @@ describe('installPackages', () => { 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', 'custom', ['demo']) expect(execFile).toHaveBeenCalledWith('pnpm', expect.arrayContaining(['remove']), { cwd: '/project/my_app', @@ -104,7 +102,7 @@ describe('installPackages', () => { }) it('passes each package as a separate arg to execFile', async () => { - await installPackages('evm', '/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', 'custom', ['demo']) const removeCall = vi .mocked(execFile) @@ -113,13 +111,13 @@ describe('installPackages', () => { 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', 'custom', ['demo']) expect(execFile).toHaveBeenCalledWith('pnpm', ['run', 'postinstall'], { cwd: '/project/my_app', @@ -130,7 +128,7 @@ describe('installPackages', () => { 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', 'custom', ['demo']) expect(exec).not.toHaveBeenCalled() }) @@ -138,32 +136,28 @@ describe('installPackages', () => { describe('onProgress callback', () => { it('reports one step for full mode', async () => { const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'full', [], (step) => steps.push(step)) + await installPackages('/project/my_app', 'full', [], (step) => steps.push(step)) expect(steps).toEqual(['Installing packages']) }) it('reports two steps for custom mode with packages to remove', async () => { const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'custom', ['demo'], (step) => - steps.push(step), - ) + await installPackages('/project/my_app', 'custom', ['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 allFeatures = featureNames const steps: string[] = [] - await installPackages('evm', '/project/my_app', 'custom', allFeatures, (step) => - steps.push(step), - ) + await installPackages('/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', 'full')).resolves.toBeUndefined() }) }) }) diff --git a/source/__tests__/utils.test.ts b/source/__tests__/utils.test.ts index 7108ea5..3adeb6a 100644 --- a/source/__tests__/utils.test.ts +++ b/source/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getFeatureEntries, getFeatureNames, stackDefinitions } from '../stacks/index.js' +import { featureDefinitions, featureEntries, featureNames } from '../config.js' import { deriveStepDisplay, describeInstallPlan, @@ -10,8 +10,6 @@ import { resolveModeFeatures, } from '../utils/utils.js' -const evmFeatures = stackDefinitions.evm.features - describe('isValidName', () => { it('accepts alphanumeric names', () => { expect(isValidName('myApp')).toBe(true) @@ -71,57 +69,57 @@ describe('isFeatureSelected', () => { 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('getPostInstallMessages', () => { it('returns every message for a scaffold that kept every feature', () => { - const result = getPostInstallMessages('evm', getFeatureNames('evm')) + 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([]) }) @@ -154,11 +152,11 @@ describe('describeInstallPlan', () => { describe('resolveModeFeatures', () => { it('returns all features for full mode', () => { - expect(resolveModeFeatures('evm', 'full')).toEqual(getFeatureNames('evm')) + expect(resolveModeFeatures('full')).toEqual(featureNames) }) it('returns the custom selection in config order', () => { - expect(resolveModeFeatures('evm', 'custom', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph']) + expect(resolveModeFeatures('custom', ['subgraph', 'demo'])).toEqual(['demo', 'subgraph']) }) }) diff --git a/source/app.tsx b/source/app.tsx index 719c461..e082f2d 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -9,11 +9,9 @@ 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 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' -const stack: Stack = 'evm' - const App: FC = () => { const [projectName, setProjectName] = useState('') const [currentStep, setCurrentStep] = useState(1) @@ -42,7 +40,7 @@ const App: FC = () => { const features = useMemo(() => { const selectedNames = selectedFeatures?.map((item) => item.value as FeatureName) ?? [] - return resolveModeFeatures(stack, mode, selectedNames) + return resolveModeFeatures(mode, selectedNames) }, [mode, selectedFeatures]) // Memoised because it feeds the step list below, which would otherwise rebuild on every render. @@ -64,7 +62,6 @@ const App: FC = () => { key={`installation-mode-${attempt}`} />, { key={`confirmation-${attempt}`} />, , { key={`file-cleanup-${attempt}`} />, 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/FileCleanup.tsx b/source/components/steps/FileCleanup.tsx index 5fae4a9..56954f0 100644 --- a/source/components/steps/FileCleanup.tsx +++ b/source/components/steps/FileCleanup.tsx @@ -1,24 +1,22 @@ 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, InstallationType } 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 = ({ mode, 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, mode, features, onProgress), + [projectFolder, mode, features], ) return ( diff --git a/source/components/steps/Install/Install.tsx b/source/components/steps/Install/Install.tsx index 3e34647..3fe4328 100644 --- a/source/components/steps/Install/Install.tsx +++ b/source/components/steps/Install/Install.tsx @@ -1,29 +1,28 @@ import { type FC, useCallback, useMemo } from 'react' import { createEnvFile, installPackages } from '../../../operations/index.js' import { completeInstall } from '../../../operations/installGuard.js' -import type { FeatureName, InstallationType, Stack } from '../../../types/types.js' +import type { FeatureName, InstallationType } 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 = ({ mode, 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) + await createEnvFile(projectFolder) + await installPackages(projectFolder, mode, features, onProgress) completeInstall() }, - [stack, projectFolder, mode, features], + [projectFolder, mode, features], ) return ( diff --git a/source/components/steps/OptionalPackages.tsx b/source/components/steps/OptionalPackages.tsx index e94fcc6..0ad3395 100644 --- a/source/components/steps/OptionalPackages.tsx +++ b/source/components/steps/OptionalPackages.tsx @@ -1,33 +1,25 @@ import { Text } from 'ink' -import { type FC, useEffect, useMemo, useState } from 'react' -import { getDefaultFeatureNames, getFeatureEntries } from '../../stacks/index.js' -import type { MultiSelectItem, Stack } from '../../types/types.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 = getDefaultFeatureNames(stack) - return customPackages.filter((pkg) => defaults.includes(pkg.value)) - }, [stack, customPackages]) - // biome-ignore lint/correctness/useExhaustiveDependencies: Run this only once, no matter what useEffect(() => { if (skip) { diff --git a/source/config.ts b/source/config.ts new file mode 100644 index 0000000..20a6d07 --- /dev/null +++ b/source/config.ts @@ -0,0 +1,102 @@ +import process from 'node:process' +import type { CleanupGroup, 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', + '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 name in featureDefinitions +} + +/** Paths the template keeps for itself. Every scaffold drops them, whatever the user picked. */ +export const prepare: CleanupGroup = { + 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: CleanupGroup = { + 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 eca6381..7bdf4f3 100644 --- a/source/info.ts +++ b/source/info.ts @@ -1,8 +1,8 @@ -import { getFeatureEntries } from './stacks/index.js' +import { featureEntries } from './config.js' export function getInfoOutput(): string { const features = Object.fromEntries( - getFeatureEntries('evm').map(([name, definition]) => [ + featureEntries.map(([name, definition]) => [ name, { description: definition.description, diff --git a/source/nonInteractive.ts b/source/nonInteractive.ts index 2faf308..79e15b8 100644 --- a/source/nonInteractive.ts +++ b/source/nonInteractive.ts @@ -1,8 +1,8 @@ import process from 'node:process' +import { featureNames, isFeatureNameValid } from './config.js' import { cleanupFiles, cloneRepo, createEnvFile, installPackages } from './operations/index.js' import { beginInstall, completeInstall } from './operations/installGuard.js' -import { getFeatureNames, isFeatureNameValid } from './stacks/index.js' -import type { FeatureName, InstallationType, Stack } from './types/types.js' +import type { FeatureName, InstallationType } from './types/types.js' import { getPostInstallMessages, getProjectFolder, @@ -59,11 +59,11 @@ 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[] } { +/** 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') } @@ -73,7 +73,7 @@ function resolveModeAndFeatures( } if (flags.mode === 'full') { - return { mode: 'full', features: resolveModeFeatures(stack, 'full') } + return { mode: 'full', features: resolveModeFeatures('full') } } if (!flags.features) { @@ -90,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) @@ -98,21 +98,18 @@ function resolveModeAndFeatures( } if (invalidFeatures.length > 0) { - const validNames = getFeatureNames(stack).join(', ') + 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: { name?: string; mode?: string; features?: string }): { - stack: Stack name: string mode: InstallationType features: FeatureName[] } { - const stack = 'evm' - if (!flags.name) { fail('Missing required flag: --name') } @@ -123,13 +120,13 @@ function validate(flags: { name?: string; mode?: string; features?: string }): { ) } - 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: { @@ -137,17 +134,17 @@ export async function runNonInteractive(flags: { 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) + await cloneRepo(name) + await cleanupFiles(projectFolder, mode, features) + await createEnvFile(projectFolder) + await installPackages(projectFolder, mode, features) completeInstall() @@ -157,7 +154,7 @@ export async function runNonInteractive(flags: { 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 635cbff..3424123 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -1,8 +1,8 @@ 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, InstallationType } from '../types/types.js' import { isFeatureSelected } from '../utils/utils.js' const HOME_FOLDER = 'src/components/pageComponents/home' @@ -24,7 +24,6 @@ async function removePaths(projectFolder: string, relativePaths: string[]): Prom * leave behind. `full` mode keeps everything. */ async function removeDeselectedFeatures( - stack: Stack, projectFolder: string, mode: InstallationType, features: FeatureName[], @@ -36,7 +35,7 @@ async function removeDeselectedFeatures( return scripts } - for (const [name, definition] of getFeatureEntries(stack)) { + for (const [name, definition] of featureEntries) { const { paths = [], scripts: featureScripts = [] } = definition if (isFeatureSelected(name, features) || (paths.length === 0 && featureScripts.length === 0)) { @@ -131,30 +130,22 @@ function patchPackageJson(projectFolder: string, scripts: string[]): 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[] = [], onProgress?: (step: string) => void, ): Promise { - const { prepare, staging } = getStackConfig(stack) + onProgress?.(prepare.label) + await removePaths(projectFolder, prepare.paths) - if (prepare) { - onProgress?.(prepare.label) - await removePaths(projectFolder, prepare.paths) - } - - const scripts = await removeDeselectedFeatures(stack, projectFolder, mode, features, onProgress) + const scripts = await removeDeselectedFeatures(projectFolder, mode, features, onProgress) - // The staged replacements only exist in stacks that ship a staging directory. - if (staging && mode !== 'full') { + if (mode !== 'full') { await restoreStagedHomePage(projectFolder, features) } patchPackageJson(projectFolder, scripts) - 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 36ee988..0d50b01 100644 --- a/source/operations/cloneRepo.ts +++ b/source/operations/cloneRepo.ts @@ -1,44 +1,40 @@ import { rm } from 'node:fs/promises' import { resolve } from 'node:path' -import { getStackConfig } from '../stacks/index.js' -import type { Stack } from '../types/types.js' +import { getRepo } 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) // 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('pnpm', ['--version']) } catch { - throw new Error( - `${config.packageManager} was not found. Install it, then run the installer again.`, - ) + throw new Error('pnpm was not found. Install it, then run the installer again.') } - if (config.ref) { + if (ref) { // Fetching the ref by name keeps the clone shallow. It works for a tag or a branch. - onProgress?.(`Cloning dAppBooster (${config.ref}) in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) + onProgress?.(`Cloning dAppBooster (${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 }) + 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 dAppBooster in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', config.repoUrl, projectName]) + await execFile('git', ['clone', '--depth', '1', '--no-checkout', url, 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/installPackages.ts b/source/operations/installPackages.ts index b658c6f..30d2150 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -1,5 +1,4 @@ -import { getStackConfig } from '../stacks/index.js' -import type { FeatureName, InstallationType, Stack } from '../types/types.js' +import type { FeatureName, InstallationType } from '../types/types.js' import { getPackagesToRemove } from '../utils/utils.js' import { execFile } from './exec.js' @@ -8,24 +7,22 @@ import { execFile } from './exec.js' * 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[] = [], onProgress?: (step: string) => void, ): Promise { - const { packageManager } = getStackConfig(stack) - const packagesToRemove = mode === 'full' ? [] : getPackagesToRemove(stack, features) + const packagesToRemove = mode === 'full' ? [] : getPackagesToRemove(features) onProgress?.('Installing packages') if (packagesToRemove.length === 0) { - await execFile(packageManager, ['install'], { cwd: projectFolder }) + await execFile('pnpm', ['install'], { cwd: projectFolder }) return } - await execFile(packageManager, ['remove', ...packagesToRemove], { cwd: projectFolder }) + await execFile('pnpm', ['remove', ...packagesToRemove], { cwd: projectFolder }) onProgress?.('Executing post-install scripts') - await execFile(packageManager, ['run', 'postinstall'], { cwd: projectFolder }) + await execFile('pnpm', ['run', 'postinstall'], { cwd: projectFolder }) } diff --git a/source/stacks/evm.ts b/source/stacks/evm.ts deleted file mode 100644 index a3d2ad4..0000000 --- a/source/stacks/evm.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { FeatureDefinition, FeatureName, StackConfig } from '../types/types.js' - -export const evm = { - 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'], - }, - 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 bd82de6..0000000 --- a/source/stacks/index.ts +++ /dev/null @@ -1,36 +0,0 @@ -import process from 'node:process' -import type { FeatureDefinition, FeatureName, Stack, StackConfig } from '../types/types.js' -import { evm } from './evm.js' - -export const stackDefinitions = { evm } satisfies Record - -function envOverride(suffix: 'REPO_URL' | 'REF'): string | undefined { - const value = process.env[`DAPPBOOSTER_${suffix}`] - return value && value.length > 0 ? value : undefined -} - -export function getStackConfig(stack: Stack): StackConfig { - const base: StackConfig = stackDefinitions[stack] - const repoUrl = envOverride('REPO_URL') ?? base.repoUrl - const ref = envOverride('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 getDefaultFeatureNames(stack: Stack): FeatureName[] { - return getFeatureEntries(stack) - .filter(([, definition]) => definition.default) - .map(([name]) => name) -} diff --git a/source/types/types.ts b/source/types/types.ts index 0fe393b..345968a 100644 --- a/source/types/types.ts +++ b/source/types/types.ts @@ -1,8 +1,4 @@ -export type Stack = 'evm' - -export type PackageManager = 'pnpm' - -/** Every feature name the EVM stack defines. `stacks/evm.ts` fails to compile if they 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' | 'custom' @@ -15,7 +11,7 @@ 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. @@ -32,33 +28,8 @@ export type FeatureDefinition = { scripts?: string[] } -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[] } - -/** - * 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 prepare - What every scaffold drops, whatever the user picked: paths the template - * keeps for itself. Applied before the package manager runs. - * @property staging - The template's staging directory, holding the replacement files a - * deselected feature restores. Removed once cleanup is finished with it. - */ -export type StackConfig = { - repoUrl: string - ref?: string - packageManager: PackageManager - prepare?: CleanupGroup - staging?: CleanupGroup - envFiles: EnvFile[] - features: Record -} diff --git a/source/utils/utils.ts b/source/utils/utils.ts index fccd435..ded7b9f 100644 --- a/source/utils/utils.ts +++ b/source/utils/utils.ts @@ -1,8 +1,8 @@ import { existsSync } from 'node:fs' import { join } from 'node:path' import process from 'node:process' -import { 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) @@ -51,16 +51,15 @@ 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: that of each feature it kept. */ -export function getPostInstallMessages(stack: Stack, features: FeatureName[]): string[] { - const config = getStackConfig(stack) - return features.flatMap((name) => config.features[name]?.postInstall ?? []) +export function getPostInstallMessages(features: FeatureName[]): string[] { + return features.flatMap((name) => featureDefinitions[name].postInstall ?? []) } /** @@ -68,12 +67,12 @@ export function getPostInstallMessages(stack: Stack, features: FeatureName[]): s * selection. Shared by the non-interactive path and the interactive steps. */ export function resolveModeFeatures( - stack: Stack, mode: InstallationType, customSelection: FeatureName[] = [], ): FeatureName[] { - const names = getFeatureNames(stack) - return mode === 'full' ? names : names.filter((name) => customSelection.includes(name)) + return mode === 'full' + ? featureNames + : featureNames.filter((name) => customSelection.includes(name)) } export function projectDirectoryExists(projectName: string): boolean { From 69bfeeafee68717e417495f1120b699cea5d833e Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:31:22 -0300 Subject: [PATCH 04/15] docs: describe the installer as EVM only The readme, CLAUDE.md and architecture.md still explained stacks, the stack flags and the Canton template. - The readme drops the stack flags and the Canton examples, and points Canton users at create-canton-dappbooster - CLAUDE.md says source/config.ts holds the feature metadata, and names the DAPPBOOSTER_REPO_URL and DAPPBOOSTER_REF overrides - architecture.md describes config.ts, the full and custom modes, and the operations without a stack argument. The guide for adding a stack is gone --- CLAUDE.md | 18 ++--- architecture.md | 199 +++++++++++++++++------------------------------- readme.md | 46 +++-------- 3 files changed, 88 insertions(+), 175 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 658ae8e..5a21e8a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,11 +8,10 @@ ## 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). It 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): React + Ink TUI that prompts for the project name, the installation mode, the optional packages and a review step, then clone → cleanup → install → post-install. +- **Non-interactive**: Flag-driven (`--ni` or auto-detected when not a TTY) for AI agents and CI. Outputs JSON to stdout. Run `--info` for feature discovery, then `--name` + `--mode` [+ `--features`]. ## Stack & Conventions @@ -27,7 +26,7 @@ A CLI installer tool for dAppBooster projects. It supports two **stacks** and tw | 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 | +| Node | v24.15.0+ | `engines.node` is the published floor, matched to what dAppBooster needs; `.nvmrc` (24) is what CI and development use | | Naming | camelCase vars/functions, PascalCase components/types | Biome enforces the same two cases for filenames | ## Code Style @@ -41,13 +40,12 @@ A CLI installer tool for dAppBooster projects. It supports two **stacks** and tw ## Working Rules -- Use **pnpm** only (never npm or yarn), for this installer and for both scaffolded projects +- Use **pnpm** only (never npm or yarn), for this installer and for the scaffolded project - 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. +- `source/config.ts` is the single source of truth for feature metadata. CLI `--help` text maintains its own copy. +- The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override the repository and ref to clone (read inside `getRepo()`), which is useful for forks and pre-release testing. +- Components are presentation-only — business logic lives in `source/operations/`. ## Architecture diff --git a/architecture.md b/architecture.md index 15a4f32..ebacf25 100644 --- a/architecture.md +++ b/architecture.md @@ -13,133 +13,99 @@ Architecture guide for the dAppBooster installer. | 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` | +| Node | v24.15.0+ published, 24 for development | `engines.node` is the floor, matched to what dAppBooster needs; `.nvmrc` is what CI uses | ## 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 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 + utils.ts Feature and mode helpers, validation, path helpers __tests__/ Mirrors source/ layout ``` ## 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 -} -``` - -`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. - -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. +- `featureDefinitions` — one entry per optional feature. `featureEntries`, `featureNames` and `defaultFeatureNames` are derived 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: 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. Removed last. +- `getRepo()` — the repository URL and an optional `ref`. Without a `ref` the newest tag is cloned. The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override both, for forks and pre-release testing. -`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. +`FeatureName` is the union of every feature name, declared in `source/types/types.ts`. `featureDefinitions` is typed `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. -`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`. +Installation modes are `full` (keep every feature) and `custom` (pick them). ### 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 + 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 instructions for non-interactive JSON output 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. +When adding 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 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 `demo` and `subgraph`, which restore replacement source files from `.install-files`. The CLI `--help` text in `cli.tsx` maintains 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. -**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-feature list, in config order: full → all, custom → 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/`) -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, no UI dependencies. Multi-step operations accept an optional `onProgress` callback for the TUI; the non-interactive path omits it. | 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 clones. Without a `ref`: shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). With `DAPPBOOSTER_REF` set, 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(projectFolder)` | Copies `.env.example` to `.env.local`. | +| `installPackages(projectFolder, mode, features, onProgress?)` | Nothing to remove (full mode, or a selection that drops no packages): `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | +| `cleanupFiles(projectFolder, mode, features, onProgress?)` | Config-driven, and runs **before** the install. First the `prepare` paths, dropped from every scaffold. Then, in `custom` mode, it loops the features and for each one the user left out removes its `paths` and collects its `scripts`, restores the demo-free home page from `.install-files` when `demo` or `subgraph` is dropped, and removes the collected scripts from package.json in a single pass, written only when a value changed. Feature dependencies are left 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 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 the install 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`, `FileCleanup`, `Install`) all call it through `StepProgress`. 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`. ### Shell Execution (`source/operations/exec.ts`) @@ -153,9 +119,9 @@ Both helpers use `spawn` with stdout ignored and stderr piped. They do not captu ### 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. +- Operations use `execFile` (no shell) for commands that include user input or 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. +- The repository URL 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. ## Data Flow @@ -165,42 +131,32 @@ Both helpers use `spawn` with stdout ignored and stderr piped. They do not captu ``` 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 +--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` required +2. `--name` matches `/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/` — a leading dash is rejected because git would read the name as an option +3. `--mode` required +4. `--mode` is `full` or `custom` +5. Full mode: skip to step 7 (every feature is kept, `--features` ignored) +6. Custom mode: `--features` required, parses to a non-empty list (rejects trailing commas, whitespace-only entries), and every name is valid +7. Project directory does not already exist **Non-interactive execution order:** -`cloneRepo` → `cleanupFiles` → `createEnvFile` → `installPackages` → `createInitialCommit` (stacks whose config sets `initialCommit`) → success JSON +`cloneRepo` → `cleanupFiles` → `createEnvFile` → `installPackages` → 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 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. 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. @@ -208,74 +164,55 @@ Any error produces `{ "success": false, "error": "..." }` and exit code 1. `repo ```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. +For full mode, `features` lists every feature name; for custom mode, 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. ``` -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). +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 the env file and the package install, and calls `completeInstall` when the scaffold is finished. -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. +`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). -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. +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(mode, selected)` before calling operations — so `full` resolves correctly even though the multiselect is skipped. The `OptionalPackages` multiselect pre-checks `default: true` features. `PostInstall` adds the subgraph warning when that feature was kept. ## Extending the Installer -### How to Add a New Stack - -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`. - -### How to Add a New Feature to an Existing Stack +### How to Add a New Feature 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. +2. **`source/config.ts`** — add an entry to `featureDefinitions` (leave one out and the file will not compile). `default: true` pre-checks it in the custom-mode multiselect. List the feature's `paths`, `scripts` and `packages`; cleanup and the install read all three, so no new code is needed. +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. nonInteractive, info, installPackages, and utils tests pick up new features automatically through `stackDefinitions`. +6. **Tests** — add assertions in the relevant test files. nonInteractive, info, installPackages, and utils tests pick up new features automatically through `featureDefinitions`. 7. **Verify** — `pnpm build && pnpm lint && pnpm test`. -> 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. - ### 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. +1. Create `source/operations/newOperation.ts` — export an async function. Use `execFile` for commands with user input, `exec` only when shell features are needed. 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. diff --git a/readme.md b/readme.md index 8406284..21ec487 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 built to quickly get you started with your next EVM project. + +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. - -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 -``` +The wizard will guide you through the different choices available: project name, installation mode, features, etc. ## 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` | +| `--info` | Print the features as JSON and exit | | `--ni`, `--non-interactive` | Force non-interactive mode. Auto-enabled 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 +``` From 2116ca017497b0dc73a0fdf7df41d9d4530a5805 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:32:20 -0300 Subject: [PATCH 05/15] docs: re-record the demo without the stack prompt The wizard no longer asks for a stack, so record-demo.py stopped waiting for that prompt, and demo.svg is a fresh recording of the current flow. --- demo.svg | 2 +- scripts/record-demo.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) 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/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 From 77e631e6bef38b07e3a8f6c65dd9c079f2195f89 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:50:49 -0300 Subject: [PATCH 06/15] fix: reject flags the installer does not know meow accepts unknown flags by default and ignores them. After the stack flags were removed, `dappbooster --canton --ni --name x --mode full` scaffolded an EVM project and exited 0. The CLI now sets allowUnknownFlags to false, so an unknown flag stops the run with exit code 2 before anything touches the disk. --- source/cli.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/source/cli.tsx b/source/cli.tsx index d07d920..52fac9c 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -47,6 +47,7 @@ const cli = meow( `, { importMeta: import.meta, + allowUnknownFlags: false, flags: { name: { type: 'string', From bd2fa31d084ef5fb72dc84e4d8739cc14db40da9 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:51:00 -0300 Subject: [PATCH 07/15] fix: accept only real feature names isFeatureNameValid used the `in` operator, which also matches keys inherited from Object.prototype. `--features constructor` passed validation and produced a project with every feature removed. It now uses Object.hasOwn, and a test covers the case. --- source/__tests__/nonInteractive.test.ts | 8 ++++++++ source/config.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index 6e18ab5..82a69bd 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -106,6 +106,14 @@ describe('nonInteractive — validation', () => { 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' }), diff --git a/source/config.ts b/source/config.ts index 20a6d07..9145fef 100644 --- a/source/config.ts +++ b/source/config.ts @@ -70,7 +70,7 @@ export const defaultFeatureNames = featureEntries .map(([name]) => name) export function isFeatureNameValid(name: string): name is FeatureName { - return name in featureDefinitions + return Object.hasOwn(featureDefinitions, name) } /** Paths the template keeps for itself. Every scaffold drops them, whatever the user picked. */ From 6646e23a1c821275ef13ebfda28376a42f1cc2fd Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:51:09 -0300 Subject: [PATCH 08/15] fix: skip postinstall when the template has none After `pnpm remove`, installPackages ran `pnpm run postinstall`, which fails when the cloned template has no such script, for example a fork set through DAPPBOOSTER_REPO_URL. The failure removed the finished project. It now runs `pnpm run --if-present postinstall`, which runs the script when it exists and does nothing otherwise. --- architecture.md | 2 +- source/__tests__/operations/installPackages.test.ts | 4 ++-- source/operations/installPackages.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/architecture.md b/architecture.md index ebacf25..f17707a 100644 --- a/architecture.md +++ b/architecture.md @@ -100,7 +100,7 @@ Plain async functions, no UI dependencies. Multi-step operations accept an optio |---|---| | `cloneRepo(projectName, onProgress?)` | Fails first when `pnpm` is not installed, before anything touches the disk. Then clones. Without a `ref`: shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). With `DAPPBOOSTER_REF` set, 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(projectFolder)` | Copies `.env.example` to `.env.local`. | -| `installPackages(projectFolder, mode, features, onProgress?)` | Nothing to remove (full mode, or a selection that drops no packages): `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | +| `installPackages(projectFolder, mode, features, onProgress?)` | Nothing to remove (full mode, or a selection that drops no packages): `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run --if-present postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | | `cleanupFiles(projectFolder, mode, features, onProgress?)` | Config-driven, and runs **before** the install. First the `prepare` paths, dropped from every scaffold. Then, in `custom` mode, it loops the features and for each one the user left out removes its `paths` and collects its `scripts`, restores the demo-free home page from `.install-files` when `demo` or `subgraph` is dropped, and removes the collected scripts from package.json in a single pass, written only when a value changed. Feature dependencies are left to `installPackages`. The staging directory goes last, once the restores no longer need it. | #### Interrupt safety (`installGuard`) diff --git a/source/__tests__/operations/installPackages.test.ts b/source/__tests__/operations/installPackages.test.ts index 28230c9..c765c50 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -69,7 +69,7 @@ describe('installPackages', () => { if (args[0] === 'remove') { callOrder.push('remove') } - if (args[0] === 'run' && args[1] === 'postinstall') { + if (args[0] === 'run' && args.includes('postinstall')) { callOrder.push('postinstall') } }) @@ -119,7 +119,7 @@ describe('installPackages', () => { it('runs postinstall via execFile', async () => { await installPackages('/project/my_app', 'custom', ['demo']) - expect(execFile).toHaveBeenCalledWith('pnpm', ['run', 'postinstall'], { + expect(execFile).toHaveBeenCalledWith('pnpm', ['run', '--if-present', 'postinstall'], { cwd: '/project/my_app', }) }) diff --git a/source/operations/installPackages.ts b/source/operations/installPackages.ts index 30d2150..3a6877b 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -24,5 +24,5 @@ export async function installPackages( await execFile('pnpm', ['remove', ...packagesToRemove], { cwd: projectFolder }) onProgress?.('Executing post-install scripts') - await execFile('pnpm', ['run', 'postinstall'], { cwd: projectFolder }) + await execFile('pnpm', ['run', '--if-present', 'postinstall'], { cwd: projectFolder }) } From 6a2e6feb3d5e07acd9e21d5c4433d62d0615d61b Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:55:30 -0300 Subject: [PATCH 09/15] fix: report unknown flags as JSON allowUnknownFlags made meow print to stderr and exit 2 on its own, so a non-interactive run with an old flag such as --canton returned no JSON for agents to parse. The flags are now declared once in a constant. After parsing, any flag not in it goes through reportFailure, which prints the usual { success: false, error } envelope and sets exit code 1. --- source/cli.tsx | 55 +++++++++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/source/cli.tsx b/source/cli.tsx index 52fac9c..67e41ef 100644 --- a/source/cli.tsx +++ b/source/cli.tsx @@ -4,6 +4,30 @@ import meow from 'meow' import { getInfoOutput } from './info.js' import { ReportedError, reportFailure, runNonInteractive } from './nonInteractive.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 @@ -47,34 +71,15 @@ const cli = meow( `, { importMeta: import.meta, - allowUnknownFlags: false, - 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, - }, - }, + flags, }, ) -if (cli.flags.info) { +const unknownFlags = Object.keys(cli.flags).filter((name) => !Object.hasOwn(flags, name)) + +if (unknownFlags.length > 0) { + reportFailure(`Unknown flags: ${unknownFlags.map((name) => `--${name}`).join(', ')}`) +} else if (cli.flags.info) { console.log(getInfoOutput()) } else if (cli.flags.nonInteractive || cli.flags.ni || !process.stdout.isTTY) { runNonInteractive({ From 2fe3477380257b15e9a63628a95f2fa1024afaf1 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:57:29 -0300 Subject: [PATCH 10/15] refactor: derive cleanup and install from the kept features cleanupFiles and installPackages took both the mode and the kept features. Every caller passes the resolved features, and full mode always means every feature, so the mode added nothing: with every feature kept, both already do no feature work. - Both now take only the project folder, the features and onProgress - FileCleanup and Install no longer take a mode prop - The tests for full mode and for "all features selected" were the same case, so they are merged --- architecture.md | 4 +- source/__tests__/nonInteractive.test.ts | 16 +--- .../__tests__/operations/cleanupFiles.test.ts | 82 ++++++++----------- .../operations/installPackages.test.ts | 57 ++++--------- source/app.tsx | 3 - source/components/steps/FileCleanup.tsx | 9 +- source/components/steps/Install/Install.tsx | 9 +- source/nonInteractive.ts | 4 +- source/operations/cleanupFiles.ts | 20 ++--- source/operations/installPackages.ts | 7 +- 10 files changed, 72 insertions(+), 139 deletions(-) diff --git a/architecture.md b/architecture.md index f17707a..7dec471 100644 --- a/architecture.md +++ b/architecture.md @@ -100,8 +100,8 @@ Plain async functions, no UI dependencies. Multi-step operations accept an optio |---|---| | `cloneRepo(projectName, onProgress?)` | Fails first when `pnpm` is not installed, before anything touches the disk. Then clones. Without a `ref`: shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). With `DAPPBOOSTER_REF` set, 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(projectFolder)` | Copies `.env.example` to `.env.local`. | -| `installPackages(projectFolder, mode, features, onProgress?)` | Nothing to remove (full mode, or a selection that drops no packages): `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run --if-present postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | -| `cleanupFiles(projectFolder, mode, features, onProgress?)` | Config-driven, and runs **before** the install. First the `prepare` paths, dropped from every scaffold. Then, in `custom` mode, it loops the features and for each one the user left out removes its `paths` and collects its `scripts`, restores the demo-free home page from `.install-files` when `demo` or `subgraph` is dropped, and removes the collected scripts from package.json in a single pass, written only when a value changed. Feature dependencies are left to `installPackages`. The staging directory goes last, once the restores no longer need it. | +| `installPackages(projectFolder, features, onProgress?)` | No packages to remove: `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run --if-present postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | +| `cleanupFiles(projectFolder, features, onProgress?)` | Config-driven, and runs **before** the install. First the `prepare` paths, dropped from every scaffold. Then it loops the features and for each one the user left out removes its `paths` and collects its `scripts`, restores the demo-free home page from `.install-files` when `demo` or `subgraph` is dropped, and removes the collected scripts from package.json in a single pass, written only when a value changed. Feature dependencies are left to `installPackages`. The staging directory goes last, once the restores no longer need it. | #### Interrupt safety (`installGuard`) diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index 82a69bd..04db3a8 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -172,16 +172,8 @@ describe('nonInteractive — full mode execution', () => { expect(cloneRepo).toHaveBeenCalledWith('my_app') expect(createEnvFile).toHaveBeenCalledWith(expect.stringContaining('my_app')) - expect(installPackages).toHaveBeenCalledWith( - expect.stringContaining('my_app'), - 'full', - featureNames, - ) - expect(cleanupFiles).toHaveBeenCalledWith( - expect.stringContaining('my_app'), - 'full', - featureNames, - ) + expect(installPackages).toHaveBeenCalledWith(expect.stringContaining('my_app'), featureNames) + expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), featureNames) }) it('outputs success JSON with all features', async () => { @@ -236,11 +228,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(expect.stringContaining('my_app'), 'custom', [ + expect(installPackages).toHaveBeenCalledWith(expect.stringContaining('my_app'), [ 'demo', 'subgraph', ]) - expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), 'custom', [ + expect(cleanupFiles).toHaveBeenCalledWith(expect.stringContaining('my_app'), [ 'demo', 'subgraph', ]) diff --git a/source/__tests__/operations/cleanupFiles.test.ts b/source/__tests__/operations/cleanupFiles.test.ts index 5a8686c..95e519b 100644 --- a/source/__tests__/operations/cleanupFiles.test.ts +++ b/source/__tests__/operations/cleanupFiles.test.ts @@ -78,9 +78,9 @@ describe('cleanupFiles', () => { mockPackageJson() }) - describe('full mode', () => { + describe('every feature kept', () => { it('removes repository metadata and .install-files', async () => { - await cleanupFiles('/project/my_app', 'full') + await cleanupFiles('/project/my_app', featureNames) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', '.install-files')) @@ -89,7 +89,7 @@ describe('cleanupFiles', () => { }) it('keeps every feature, so package.json is left untouched', async () => { - await cleanupFiles('/project/my_app', 'full') + await cleanupFiles('/project/my_app', featureNames) expect(getRmPaths()).not.toContain(resolve('/project/my_app', '.husky')) expect(writeFileSync).not.toHaveBeenCalled() @@ -97,25 +97,9 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — all features selected', () => { - it('removes the repository metadata plus .install-files', async () => { - await cleanupFiles('/project/my_app', 'custom', featureNames) - - 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('/project/my_app', 'custom', featureNames) - - expect(writeFileSync).not.toHaveBeenCalled() - }) - }) - - describe('custom mode — demo deselected', () => { + describe('demo deselected', () => { it('removes home folder, recreates it, copies replacement', async () => { - await cleanupFiles('/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) @@ -129,15 +113,15 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — subgraph deselected', () => { + describe('subgraph deselected', () => { it('removes src/subgraphs', async () => { - await cleanupFiles('/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('/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')) @@ -151,7 +135,7 @@ describe('cleanupFiles', () => { }) it('does NOT clean up subgraph demos when demo is also deselected', async () => { - await cleanupFiles('/project/my_app', 'custom', ['typedoc', 'vocs', 'husky']) + await cleanupFiles('/project/my_app', ['typedoc', 'vocs', 'husky']) const subgraphDemosPath = resolve( '/project/my_app', @@ -161,7 +145,7 @@ describe('cleanupFiles', () => { }) it('removes subgraph-codegen from package.json scripts', async () => { - await cleanupFiles('/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 @@ -169,15 +153,15 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — typedoc deselected', () => { + describe('typedoc deselected', () => { it('removes typedoc.json', async () => { - await cleanupFiles('/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('/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 @@ -185,16 +169,16 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — vocs deselected', () => { + describe('vocs deselected', () => { it('removes vocs.config.ts and docs folder', async () => { - await cleanupFiles('/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('/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 @@ -204,9 +188,9 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — husky deselected', () => { + describe('husky deselected', () => { it('removes husky folder and config files', async () => { - await cleanupFiles('/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')) @@ -214,7 +198,7 @@ describe('cleanupFiles', () => { }) it('removes the tooling scripts from package.json', async () => { - await cleanupFiles('/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 @@ -223,7 +207,7 @@ describe('cleanupFiles', () => { }) it('leaves the dependencies to the package manager', async () => { - await cleanupFiles('/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') @@ -231,9 +215,9 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — husky selected', () => { + describe('husky selected', () => { it('keeps the husky files, scripts, and dependencies', async () => { - await cleanupFiles('/project/my_app', 'custom', featureNames) + await cleanupFiles('/project/my_app', featureNames) const paths = getRmPaths() expect(paths).not.toContain(resolve('/project/my_app', '.husky')) @@ -243,9 +227,9 @@ describe('cleanupFiles', () => { }) }) - describe('custom mode — no features selected', () => { + describe('no features kept', () => { it('runs all cleanup operations', async () => { - await cleanupFiles('/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', []) const paths = getRmPaths() expect(paths).toContain(resolve('/project/my_app', 'src/components/pageComponents/home')) @@ -257,7 +241,7 @@ describe('cleanupFiles', () => { }) it('removes all optional scripts from package.json', async () => { - await cleanupFiles('/project/my_app', 'custom', []) + await cleanupFiles('/project/my_app', []) const pkg = getWrittenPackageJson() const scripts = pkg.scripts as Record @@ -273,14 +257,14 @@ describe('cleanupFiles', () => { }) it('always removes .install-files as the last rm call', async () => { - await cleanupFiles('/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('/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 } @@ -289,16 +273,16 @@ describe('cleanupFiles', () => { }) 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('/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('/project/my_app', 'custom', [], (step) => steps.push(step)) + await cleanupFiles('/project/my_app', [], (step) => steps.push(step)) expect(steps).toEqual([ 'Repository metadata', @@ -313,9 +297,7 @@ describe('cleanupFiles', () => { it('skips steps for selected features', async () => { const steps: string[] = [] - await cleanupFiles('/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') @@ -324,7 +306,7 @@ describe('cleanupFiles', () => { }) it('works without a callback', async () => { - await expect(cleanupFiles('/project/my_app', 'full')).resolves.toBeUndefined() + await expect(cleanupFiles('/project/my_app', featureNames)).resolves.toBeUndefined() }) }) }) diff --git a/source/__tests__/operations/installPackages.test.ts b/source/__tests__/operations/installPackages.test.ts index c765c50..c2caf61 100644 --- a/source/__tests__/operations/installPackages.test.ts +++ b/source/__tests__/operations/installPackages.test.ts @@ -14,40 +14,23 @@ describe('installPackages', () => { vi.clearAllMocks() }) - describe('full mode', () => { + describe('every feature kept', () => { it('runs pnpm install via execFile', async () => { - await installPackages('/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('/project/my_app', 'full') + await installPackages('/project/my_app', featureNames) expect(execFile).toHaveBeenCalledTimes(1) }) - - it('ignores features argument', async () => { - await installPackages('/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 = featureNames - await installPackages('/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('/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) const removeCall = vi .mocked(execFile) @@ -74,13 +57,13 @@ describe('installPackages', () => { } }) - await installPackages('/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('/project/my_app', 'custom', ['demo', 'subgraph']) + await installPackages('/project/my_app', ['demo', 'subgraph']) const removeCall = vi .mocked(execFile) @@ -94,7 +77,7 @@ describe('installPackages', () => { }) it('uses execFile for pnpm remove to avoid shell interpolation', async () => { - await installPackages('/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) expect(execFile).toHaveBeenCalledWith('pnpm', expect.arrayContaining(['remove']), { cwd: '/project/my_app', @@ -102,7 +85,7 @@ describe('installPackages', () => { }) it('passes each package as a separate arg to execFile', async () => { - await installPackages('/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) const removeCall = vi .mocked(execFile) @@ -117,7 +100,7 @@ describe('installPackages', () => { }) it('runs postinstall via execFile', async () => { - await installPackages('/project/my_app', 'custom', ['demo']) + await installPackages('/project/my_app', ['demo']) expect(execFile).toHaveBeenCalledWith('pnpm', ['run', '--if-present', 'postinstall'], { cwd: '/project/my_app', @@ -128,36 +111,28 @@ describe('installPackages', () => { it('never uses exec (shell) for any command', async () => { const { exec } = await import('../../operations/exec.js') - await installPackages('/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('/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('/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 = featureNames - const steps: string[] = [] - await installPackages('/project/my_app', 'custom', allFeatures, (step) => steps.push(step)) - - expect(steps).toEqual(['Installing packages']) - }) - it('works without a callback', async () => { - await expect(installPackages('/project/my_app', 'full')).resolves.toBeUndefined() + await expect(installPackages('/project/my_app', featureNames)).resolves.toBeUndefined() }) }) }) diff --git a/source/app.tsx b/source/app.tsx index e082f2d..420a433 100644 --- a/source/app.tsx +++ b/source/app.tsx @@ -79,14 +79,12 @@ const App: FC = () => { key={`clone-repo-${attempt}`} />, , { finishStep, onSelectSelectedFeatures, onSelectSetupType, - mode, features, projectName, skipFeatures, diff --git a/source/components/steps/FileCleanup.tsx b/source/components/steps/FileCleanup.tsx index 56954f0..e916338 100644 --- a/source/components/steps/FileCleanup.tsx +++ b/source/components/steps/FileCleanup.tsx @@ -1,22 +1,21 @@ import { type FC, useCallback, useMemo } from 'react' import { cleanupFiles } from '../../operations/index.js' -import type { FeatureName, InstallationType } from '../../types/types.js' +import type { FeatureName } from '../../types/types.js' import { getProjectFolder } from '../../utils/utils.js' import StepProgress from './StepProgress.js' interface Props { - mode: InstallationType features: FeatureName[] onCompletion: () => void projectName: string } -const FileCleanup: FC = ({ mode, features, onCompletion, projectName }) => { +const FileCleanup: FC = ({ features, onCompletion, projectName }) => { const projectFolder = useMemo(() => getProjectFolder(projectName), [projectName]) const run = useCallback( - (onProgress: (step: string) => void) => cleanupFiles(projectFolder, mode, features, onProgress), - [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 3fe4328..b7b8ed6 100644 --- a/source/components/steps/Install/Install.tsx +++ b/source/components/steps/Install/Install.tsx @@ -1,28 +1,27 @@ import { type FC, useCallback, useMemo } from 'react' import { createEnvFile, installPackages } from '../../../operations/index.js' import { completeInstall } from '../../../operations/installGuard.js' -import type { FeatureName, InstallationType } from '../../../types/types.js' +import type { FeatureName } from '../../../types/types.js' import { getProjectFolder } from '../../../utils/utils.js' import StepProgress from '../StepProgress.js' interface Props { - mode: InstallationType features: FeatureName[] projectName: string onCompletion: () => void } -const Install: FC = ({ 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(projectFolder) - await installPackages(projectFolder, mode, features, onProgress) + await installPackages(projectFolder, features, onProgress) completeInstall() }, - [projectFolder, mode, features], + [projectFolder, features], ) return ( diff --git a/source/nonInteractive.ts b/source/nonInteractive.ts index 79e15b8..0967f65 100644 --- a/source/nonInteractive.ts +++ b/source/nonInteractive.ts @@ -142,9 +142,9 @@ export async function runNonInteractive(flags: { beginInstall(projectFolder) await cloneRepo(name) - await cleanupFiles(projectFolder, mode, features) + await cleanupFiles(projectFolder, features) await createEnvFile(projectFolder) - await installPackages(projectFolder, mode, features) + await installPackages(projectFolder, features) completeInstall() diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index 3424123..08fc76e 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -2,7 +2,7 @@ import { readFileSync, writeFileSync } from 'node:fs' import { copyFile, mkdir, rm } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { featureEntries, prepare, staging } from '../config.js' -import type { FeatureName, InstallationType } from '../types/types.js' +import type { FeatureName } from '../types/types.js' import { isFeatureSelected } from '../utils/utils.js' const HOME_FOLDER = 'src/components/pageComponents/home' @@ -21,20 +21,15 @@ async function removePaths(projectFolder: string, relativePaths: string[]): Prom /** * Deletes the files of every feature the user left out and returns the package.json scripts they - * leave behind. `full` mode keeps everything. + * leave behind. */ async function removeDeselectedFeatures( projectFolder: string, - mode: InstallationType, features: FeatureName[], onProgress?: (step: string) => void, ): Promise { const scripts: string[] = [] - if (mode === 'full') { - return scripts - } - for (const [name, definition] of featureEntries) { const { paths = [], scripts: featureScripts = [] } = definition @@ -131,19 +126,14 @@ function patchPackageJson(projectFolder: string, scripts: string[]): void { */ export async function cleanupFiles( projectFolder: string, - mode: InstallationType, - features: FeatureName[] = [], + features: FeatureName[], onProgress?: (step: string) => void, ): Promise { onProgress?.(prepare.label) await removePaths(projectFolder, prepare.paths) - const scripts = await removeDeselectedFeatures(projectFolder, mode, features, onProgress) - - if (mode !== 'full') { - await restoreStagedHomePage(projectFolder, features) - } - + const scripts = await removeDeselectedFeatures(projectFolder, features, onProgress) + await restoreStagedHomePage(projectFolder, features) patchPackageJson(projectFolder, scripts) onProgress?.(staging.label) diff --git a/source/operations/installPackages.ts b/source/operations/installPackages.ts index 3a6877b..1406da4 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -1,4 +1,4 @@ -import type { FeatureName, InstallationType } from '../types/types.js' +import type { FeatureName } from '../types/types.js' import { getPackagesToRemove } from '../utils/utils.js' import { execFile } from './exec.js' @@ -8,11 +8,10 @@ import { execFile } from './exec.js' */ export async function installPackages( projectFolder: string, - mode: InstallationType, - features: FeatureName[] = [], + features: FeatureName[], onProgress?: (step: string) => void, ): Promise { - const packagesToRemove = mode === 'full' ? [] : getPackagesToRemove(features) + const packagesToRemove = getPackagesToRemove(features) onProgress?.('Installing packages') From f11988f6d7971c875ef0e138359b4e3076df73d6 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:58:37 -0300 Subject: [PATCH 11/15] refactor: tidy the code left after the stack removal Small cleanups in code this branch already touched. Nothing changes for users. - cleanupFiles: removeKeys had one caller left, so patchPackageJson removes the scripts itself - info.ts: JSON.stringify already drops undefined values, so the conditional spread for postInstall is gone - Removed the CleanupGroup type. TypeScript infers prepare and staging - cloneRepo runs one git clone, then branches only for the checkout - resolveModeFeatures uses isFeatureSelected - InstallationMode builds its list from MODE_LABELS, so a new mode shows up in the wizard without a second edit - The default mode test was a copy of the invalid mode test, so the two are merged --- source/__tests__/nonInteractive.test.ts | 18 ++---------- source/components/steps/InstallationMode.tsx | 6 ++-- source/config.ts | 6 ++-- source/info.ts | 2 +- source/operations/cleanupFiles.ts | 30 +++++++------------- source/operations/cloneRepo.ts | 9 ++---- source/types/types.ts | 6 ---- source/utils/utils.ts | 2 +- 8 files changed, 24 insertions(+), 55 deletions(-) diff --git a/source/__tests__/nonInteractive.test.ts b/source/__tests__/nonInteractive.test.ts index 04db3a8..0450024 100644 --- a/source/__tests__/nonInteractive.test.ts +++ b/source/__tests__/nonInteractive.test.ts @@ -67,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 () => { @@ -205,20 +205,6 @@ describe('nonInteractive — full mode execution', () => { }) }) -describe('nonInteractive — default mode', () => { - beforeEach(() => { - vi.clearAllMocks() - process.exitCode = undefined - }) - - it('rejects --mode default', async () => { - await expect(runNonInteractive({ name: 'my_app', mode: 'default' })).rejects.toThrow() - const output = getLastJsonOutput() - expect(output.success).toBe(false) - expect(output.error).toBe("Invalid mode: must be 'full' or 'custom'") - }) -}) - describe('nonInteractive — custom mode execution', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/source/components/steps/InstallationMode.tsx b/source/components/steps/InstallationMode.tsx index d3f73ec..371857a 100644 --- a/source/components/steps/InstallationMode.tsx +++ b/source/components/steps/InstallationMode.tsx @@ -2,7 +2,7 @@ import figures from 'figures' import { Text } from 'ink' import SelectInput from 'ink-select-input' import { type FC, useState } from 'react' -import type { InstallationSelectItem } from '../../types/types.js' +import type { InstallationSelectItem, InstallationType } from '../../types/types.js' import { MODE_LABELS } from '../../utils/utils.js' import Divider from '../Divider.js' @@ -11,8 +11,8 @@ interface Props { onSelect: (item: InstallationSelectItem) => void } -const installationTypeItems: Array = (['full', 'custom'] as const).map( - (value) => ({ label: MODE_LABELS[value], value }), +const installationTypeItems: Array = Object.entries(MODE_LABELS).map( + ([value, label]) => ({ label, value: value as InstallationType }), ) const InstallationMode: FC = ({ onCompletion, onSelect }) => { diff --git a/source/config.ts b/source/config.ts index 9145fef..acd6ba0 100644 --- a/source/config.ts +++ b/source/config.ts @@ -1,5 +1,5 @@ import process from 'node:process' -import type { CleanupGroup, FeatureDefinition, FeatureName } from './types/types.js' +import type { FeatureDefinition, FeatureName } from './types/types.js' export const featureDefinitions: Record = { demo: { @@ -74,7 +74,7 @@ export function isFeatureNameValid(name: string): name is FeatureName { } /** Paths the template keeps for itself. Every scaffold drops them, whatever the user picked. */ -export const prepare: CleanupGroup = { +export const prepare = { label: 'Repository metadata', paths: ['.claude', 'AGENTS.md', 'CLAUDE.md', 'architecture.md', '.github'], } @@ -83,7 +83,7 @@ export const prepare: CleanupGroup = { * The template's staging directory, holding the replacement files a deselected feature restores. * Removed once cleanup is finished with it. */ -export const staging: CleanupGroup = { +export const staging = { label: 'Install script', paths: ['.install-files'], } diff --git a/source/info.ts b/source/info.ts index 7bdf4f3..919cf08 100644 --- a/source/info.ts +++ b/source/info.ts @@ -7,7 +7,7 @@ export function getInfoOutput(): string { { description: definition.description, default: definition.default, - ...(definition.postInstall ? { postInstall: definition.postInstall } : {}), + postInstall: definition.postInstall, }, ]), ) diff --git a/source/operations/cleanupFiles.ts b/source/operations/cleanupFiles.ts index 08fc76e..ab2d949 100644 --- a/source/operations/cleanupFiles.ts +++ b/source/operations/cleanupFiles.ts @@ -78,23 +78,6 @@ 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 -} - /** * 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. @@ -114,9 +97,18 @@ function patchPackageJson(projectFolder: string, scripts: string[]): void { return } - if (removeKeys(packageJson.scripts, scripts)) { - writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) + const current = packageJson.scripts ?? {} + const present = scripts.filter((name) => name in current) + + if (present.length === 0) { + return + } + + for (const name of present) { + delete current[name] } + + writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`) } /** diff --git a/source/operations/cloneRepo.ts b/source/operations/cloneRepo.ts index 0d50b01..de9a2ff 100644 --- a/source/operations/cloneRepo.ts +++ b/source/operations/cloneRepo.ts @@ -24,18 +24,15 @@ export async function cloneRepo( throw new Error('pnpm was not found. Install it, then run the installer again.') } + onProgress?.(`Cloning dAppBooster${ref ? ` (${ref})` : ''} in ${projectName}`) + await execFile('git', ['clone', '--depth', '1', '--no-checkout', url, projectName]) + if (ref) { // Fetching the ref by name keeps the clone shallow. It works for a tag or a branch. - onProgress?.(`Cloning dAppBooster (${ref}) in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', url, projectName]) - 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 dAppBooster in ${projectName}`) - await execFile('git', ['clone', '--depth', '1', '--no-checkout', url, projectName]) - onProgress?.('Fetching tags') await execFile('git', ['fetch', '--tags'], { cwd: projectFolder }) diff --git a/source/types/types.ts b/source/types/types.ts index 345968a..feede79 100644 --- a/source/types/types.ts +++ b/source/types/types.ts @@ -27,9 +27,3 @@ export type FeatureDefinition = { paths?: string[] scripts?: string[] } - -/** A labelled group of paths cleanup removes whatever the user picked. */ -export type CleanupGroup = { - label: string - paths: string[] -} diff --git a/source/utils/utils.ts b/source/utils/utils.ts index ded7b9f..81f11e6 100644 --- a/source/utils/utils.ts +++ b/source/utils/utils.ts @@ -72,7 +72,7 @@ export function resolveModeFeatures( ): FeatureName[] { return mode === 'full' ? featureNames - : featureNames.filter((name) => customSelection.includes(name)) + : featureNames.filter((name) => isFeatureSelected(name, customSelection)) } export function projectDirectoryExists(projectName: string): boolean { From d510be80e1bc9244b39e4ee77861aa50ec4f4d48 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:22:13 -0300 Subject: [PATCH 12/15] refactor: read the subgraph steps from the config The wizard's subgraph warning wrote out its own copy of the two setup steps, while the JSON output read them from featureDefinitions.subgraph.postInstall. The two had already drifted apart. - PostInstall now prints the steps from the config - The config text gains The Graph Studio URL, so agents get it too - The step text in the wizard is no longer gray --- source/components/steps/PostInstall.tsx | 15 ++++++--------- source/config.ts | 4 ++-- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/source/components/steps/PostInstall.tsx b/source/components/steps/PostInstall.tsx index d513058..b566147 100644 --- a/source/components/steps/PostInstall.tsx +++ b/source/components/steps/PostInstall.tsx @@ -2,6 +2,7 @@ import figures from 'figures' import { Box, Text } from 'ink' 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' @@ -33,15 +34,11 @@ const SubgraphWarningMessage: FC = () => ( 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 - + {featureDefinitions.subgraph.postInstall?.map((step, index) => ( + + {index + 1}- {step} + + ))} More configuration info in the{' '} diff --git a/source/config.ts b/source/config.ts index acd6ba0..e15afe1 100644 --- a/source/config.ts +++ b/source/config.ts @@ -23,8 +23,8 @@ export const featureDefinitions: Record = { 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', + '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: { From 9d9e0fe8326b0c15afb452acf9e91b178662990f Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:22:53 -0300 Subject: [PATCH 13/15] refactor: name the package manager once cloneRepo and installPackages wrote 'pnpm' five times between them. config.ts now exports packageManager, and both operations read it. --- source/config.ts | 2 ++ source/operations/cloneRepo.ts | 6 +++--- source/operations/installPackages.ts | 7 ++++--- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/source/config.ts b/source/config.ts index e15afe1..5325be9 100644 --- a/source/config.ts +++ b/source/config.ts @@ -73,6 +73,8 @@ 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', diff --git a/source/operations/cloneRepo.ts b/source/operations/cloneRepo.ts index de9a2ff..2af3c66 100644 --- a/source/operations/cloneRepo.ts +++ b/source/operations/cloneRepo.ts @@ -1,6 +1,6 @@ import { rm } from 'node:fs/promises' import { resolve } from 'node:path' -import { getRepo } from '../config.js' +import { getRepo, packageManager } from '../config.js' import { getProjectFolder } from '../utils/utils.js' import { exec, execFile } from './exec.js' @@ -19,9 +19,9 @@ export async function cloneRepo( // The installer runs this a few steps later. Checking now saves a clone that cannot be used. try { - await execFile('pnpm', ['--version']) + await execFile(packageManager, ['--version']) } catch { - throw new Error('pnpm was not found. Install it, then run the installer again.') + throw new Error(`${packageManager} was not found. Install it, then run the installer again.`) } onProgress?.(`Cloning dAppBooster${ref ? ` (${ref})` : ''} in ${projectName}`) diff --git a/source/operations/installPackages.ts b/source/operations/installPackages.ts index 1406da4..f7bdbbc 100644 --- a/source/operations/installPackages.ts +++ b/source/operations/installPackages.ts @@ -1,3 +1,4 @@ +import { packageManager } from '../config.js' import type { FeatureName } from '../types/types.js' import { getPackagesToRemove } from '../utils/utils.js' import { execFile } from './exec.js' @@ -16,12 +17,12 @@ export async function installPackages( onProgress?.('Installing packages') if (packagesToRemove.length === 0) { - await execFile('pnpm', ['install'], { cwd: projectFolder }) + await execFile(packageManager, ['install'], { cwd: projectFolder }) return } - await execFile('pnpm', ['remove', ...packagesToRemove], { cwd: projectFolder }) + await execFile(packageManager, ['remove', ...packagesToRemove], { cwd: projectFolder }) onProgress?.('Executing post-install scripts') - await execFile('pnpm', ['run', '--if-present', 'postinstall'], { cwd: projectFolder }) + await execFile(packageManager, ['run', '--if-present', 'postinstall'], { cwd: projectFolder }) } From 8e1f12e152163f14fed3bd97a47260aa2c13ecfb Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:23:50 -0300 Subject: [PATCH 14/15] refactor: return the multiselect choice in list order MultiSelect handed back its selection in click order, so OptionalPackages sorted it back into config order before using it. MultiSelect now submits the selected items in the order of its items list, which is what a checkbox list shows. OptionalPackages passes the selection on as it is. --- source/components/Multiselect/MultiSelect.tsx | 4 ++-- source/components/steps/OptionalPackages.tsx | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/source/components/Multiselect/MultiSelect.tsx b/source/components/Multiselect/MultiSelect.tsx index 0d4170a..36984c9 100644 --- a/source/components/Multiselect/MultiSelect.tsx +++ b/source/components/Multiselect/MultiSelect.tsx @@ -71,8 +71,8 @@ const MultiSelect = ({ ) 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/OptionalPackages.tsx b/source/components/steps/OptionalPackages.tsx index 0ad3395..65efc8c 100644 --- a/source/components/steps/OptionalPackages.tsx +++ b/source/components/steps/OptionalPackages.tsx @@ -28,11 +28,8 @@ const OptionalPackages: FC = ({ onCompletion, onSubmit, skip = false }) = }, []) const onHandleSubmit = (selectedItems: Array) => { - const inConfigOrder = customPackages.filter((pkg) => - selectedItems.some((item) => item.value === pkg.value), - ) - onSubmit(inConfigOrder) - setSubmitted(inConfigOrder) + onSubmit(selectedItems) + setSubmitted(selectedItems) onCompletion() } From a0a3ad40a90dc41f25f712425401bc221fb87a65 Mon Sep 17 00:00:00 2001 From: Gabito Esmiapodo <4015436+gabitoesmiapodo@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:36:42 -0300 Subject: [PATCH 15/15] docs: sync the docs and tighten their wording The docs missed the latest changes, and Vale and an AI-tells pass flagged their wording. - architecture.md lists packageManager, the JSON error for unknown flags, and the subgraph steps PostInstall reads from the config - CLAUDE.md says config.ts holds the features, the cleanup paths, the repository and the package manager - Headings use sentence case. Dashes and arrows in prose became plain sentences, and passive phrases became active where the actor is known - The CI order in CLAUDE.md is now a numbered list, and "gates" became "checks" - The readme keeps its layout. Only a few phrases changed --- AGENTS.md | 4 +- CLAUDE.md | 140 +++++++++++++++++++++++++----------------------- architecture.md | 136 ++++++++++++++++++++++++---------------------- readme.md | 8 +-- 4 files changed, 153 insertions(+), 135 deletions(-) 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 5a21e8a..14166b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,19 +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 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). It has 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: -- **Interactive** (default): React + Ink TUI that prompts for the project name, the installation mode, the optional packages and a review step, then clone → cleanup → install → post-install. -- **Non-interactive**: Flag-driven (`--ni` or auto-detected when not a TTY) for AI agents and CI. Outputs JSON to stdout. Run `--info` for feature discovery, then `--name` + `--mode` [+ `--features`]. +- **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 | |----------|-----------|-------| @@ -25,43 +25,43 @@ A CLI installer for [dAppBooster](https://github.com/BootNodeDev/dAppBooster), t | 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 dAppBooster needs; `.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 the scaffolded project -- 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/config.ts` is the single source of truth for feature metadata. CLI `--help` text maintains its own copy. -- The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override the repository and ref to clone (read inside `getRepo()`), which is useful for forks and pre-release testing. -- Components are presentation-only — business logic lives in `source/operations/`. +- 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 @@ -70,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 @@ -94,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 @@ -141,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 #`) @@ -168,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 @@ -197,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. @@ -222,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 7dec471..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,10 +12,10 @@ 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 dAppBooster needs; `.nvmrc` is what CI uses | +| 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/ @@ -23,13 +23,13 @@ source/ 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 - config.ts Feature definitions, prepare and staging paths, the repository to clone + 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 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 + 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/ @@ -51,26 +51,27 @@ source/ types.ts Shared TypeScript types utils/ utils.ts Feature and mode helpers, validation, path helpers - __tests__/ Mirrors source/ layout + __tests__/ Mirrors the source/ layout ``` -## Key Abstractions +## Key abstractions ### Config (`source/config.ts`) Everything the installer knows about the dAppBooster template: -- `featureDefinitions` — one entry per optional feature. `featureEntries`, `featureNames` and `defaultFeatureNames` are derived 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: 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. Removed last. -- `getRepo()` — the repository URL and an optional `ref`. Without a `ref` the newest tag is cloned. The env vars `DAPPBOOSTER_REPO_URL` and `DAPPBOOSTER_REF` override both, for forks and pre-release testing. +- `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. -`FeatureName` is the union of every feature name, declared in `source/types/types.ts`. `featureDefinitions` is typed `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. +`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 `full` (keep every feature) and `custom` (pick them). -### Feature Definitions +### Feature definitions Shape: @@ -80,51 +81,59 @@ type FeatureDefinition = { 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 instructions for non-interactive JSON output + 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 } ``` -When adding 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 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 `demo` and `subgraph`, which restore replacement source files from `.install-files`. 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. -`resolveModeFeatures(mode, customSelection)` in `utils.ts` maps a mode to its kept-feature list, in config order: full → all, custom → 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. +`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. 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(projectName, onProgress?)` | Fails first when `pnpm` is not installed, before anything touches the disk. Then clones. Without a `ref`: shallow clone with `--no-checkout`, `git fetch --tags`, then `git checkout $(git describe --tags …)` (shell required for `$()`). With `DAPPBOOSTER_REF` set, 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. | +| `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?)` | No packages to remove: `pnpm install`. Otherwise `pnpm remove`, which prunes the manifest and the lockfile together, then `pnpm run --if-present postinstall`. Runs after `cleanupFiles`, so it resolves the pruned manifest once. `execFile` only — never shell. | -| `cleanupFiles(projectFolder, features, onProgress?)` | Config-driven, and runs **before** the install. First the `prepare` paths, dropped from every scaffold. Then it loops the features and for each one the user left out removes its `paths` and collects its `scripts`, restores the demo-free home page from `.install-files` when `demo` or `subgraph` is dropped, and removes the collected scripts from package.json in a single pass, written only when a value changed. Feature dependencies are left to `installPackages`. The staging directory goes last, once the restores no longer need it. | +| `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 the install 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`, `FileCleanup`, `Install`) all call it through `StepProgress`. 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. -### Shell Execution (`source/operations/exec.ts`) +- `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. + +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. +- 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. -- `exec` (shell) is reserved for the tag-latest checkout (`git checkout $(git describe …)`); it never receives user input in the command string. -- The repository URL 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. +- 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) @@ -139,26 +148,27 @@ CLI flags (string) **Routing:** `source/cli.tsx` ``` +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 → TUI ``` **Non-interactive validation order:** -1. `--name` required -2. `--name` matches `/^[a-zA-Z0-9_][a-zA-Z0-9_-]*$/` — a leading dash is rejected because git would read the name as an option -3. `--mode` required +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 (every feature is kept, `--features` ignored) -6. Custom mode: `--features` required, parses to a non-empty list (rejects trailing commas, whitespace-only entries), and every name is valid -7. Project directory does not already exist +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` → 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. +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 @@ -172,7 +182,7 @@ Any error produces `{ "success": false, "error": "..." }` and exit code 1. `repo } ``` -For full mode, `features` lists every feature name; for custom mode, the selected ones in config order. +In full mode, `features` lists every feature name. In custom mode it lists the selected ones, in config order. ### Interactive (human) @@ -185,34 +195,34 @@ User input via Ink components → 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 → InstallationMode → OptionalPackages (custom only) → Confirmation Operations (disk): CloneRepo → FileCleanup → Install → PostInstall ``` -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 the env file and the package install, and calls `completeInstall` when the scaffold is finished. +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. -`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). +`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. -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(mode, selected)` before calling operations — so `full` resolves correctly even though the multiselect is skipped. The `OptionalPackages` multiselect pre-checks `default: true` features. `PostInstall` adds the subgraph warning when that feature was kept. +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. -## Extending the Installer +## Extending the installer -### How to Add a New Feature +### How to add a new feature -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 will not compile). `default: true` pre-checks it in the custom-mode multiselect. List the feature's `paths`, `scripts` and `packages`; cleanup and the install read all three, so no new code is needed. -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. nonInteractive, info, installPackages, and utils tests pick up new features automatically through `featureDefinitions`. -7. **Verify** — `pnpm build && pnpm lint && pnpm test`. +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. -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/readme.md b/readme.md index 21ec487..d70ff35 100644 --- a/readme.md +++ b/readme.md @@ -1,6 +1,6 @@ # dAppBooster installer -Scaffolds a new project from [dAppBooster](https://dappbooster.dev/), our blockchain boilerplate built to quickly get you started with your next EVM project. +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). @@ -17,9 +17,9 @@ Looking for Canton? It has its own installer: [`create-canton-dappbooster`](http pnpm dlx dappbooster ``` -The wizard will guide you through the different choices available: project name, installation mode, features, etc. +The wizard guides you through the choices: project name, installation mode and features. -## Non-interactive mode (Agents & CI) +## Non-interactive mode (agents & CI) | Flag | Purpose | |---|---| @@ -27,7 +27,7 @@ The wizard will guide you through the different choices available: project name, | `--mode ` | `full` installs every feature; `custom` needs `--features` | | `--features ` | Comma-separated feature keys (custom mode only) | | `--info` | Print the features as JSON and exit | -| `--ni`, `--non-interactive` | Force non-interactive mode. Auto-enabled when stdout is not a TTY | +| `--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 |