diff --git a/create-bestax/e2e/utils/scaffold.ts b/create-bestax/e2e/utils/scaffold.ts index 9c04cf17c..f30e10c5d 100644 --- a/create-bestax/e2e/utils/scaffold.ts +++ b/create-bestax/e2e/utils/scaffold.ts @@ -71,6 +71,13 @@ export async function scaffoldApp(config: ScaffoldConfig): Promise { throw new Error(`Expected scaffold to contain ${launchJsonPath}`); } + // npm strips literal .gitignore from published tarballs; the CLI renames + // the templates' _gitignore on copy — fail loudly if that path regresses. + const gitignorePath = path.join(outputDir, '.gitignore'); + if (!(await fs.pathExists(gitignorePath))) { + throw new Error(`Expected scaffold to contain ${gitignorePath}`); + } + // Install dependencies in the scaffolded app with a fresh pnpm resolve. // --ignore-workspace: the app may live inside the monorepo tree, so treat it // as a standalone project rather than a workspace member. diff --git a/create-bestax/src/__tests__/constants.test.ts b/create-bestax/src/__tests__/constants.test.ts index e0735c596..c2305c777 100644 --- a/create-bestax/src/__tests__/constants.test.ts +++ b/create-bestax/src/__tests__/constants.test.ts @@ -241,6 +241,18 @@ describe('constants', () => { expect(content).toContain('meta tags'); expect(content).toContain('rewrite the README'); }); + + it('tells agents how to recover from a strictPort collision without moving ports', () => { + const content = CLAUDE_MD('my-app', { + bulmaFlavor: 'complete', + iconLibrary: 'none', + }); + expect(content).toContain('--strictPort'); + // Listener-only: a plain `lsof -ti:5173` would also match clients + // connected to the port (e.g. the preview browser) and kill them. + expect(content).toContain('lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill'); + expect(content).toMatch(/orphaned dev server/); + }); }); describe('CLAUDE_MD house style', () => { diff --git a/create-bestax/src/__tests__/display.test.ts b/create-bestax/src/__tests__/display.test.ts index 9c1845f9b..4e1906e4c 100644 --- a/create-bestax/src/__tests__/display.test.ts +++ b/create-bestax/src/__tests__/display.test.ts @@ -108,17 +108,46 @@ describe('display', () => { expect(output).toContain(projectName); }); - it('should include pnpm commands', () => { - displaySuccess('my-app'); + describe('next-steps package manager', () => { + const originalUserAgent = process.env.npm_config_user_agent; - const calls = (console.log as jest.MockedFunction) - .mock.calls; - const output = calls.map(call => call.join(' ')).join('\n'); + beforeEach(() => { + delete process.env.npm_config_user_agent; + }); + + afterEach(() => { + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent; + } else { + process.env.npm_config_user_agent = originalUserAgent; + } + }); + + it('should fall back to npm commands without an invoking package manager', () => { + displaySuccess('my-app'); + + const calls = (console.log as jest.MockedFunction) + .mock.calls; + const output = calls.map(call => call.join(' ')).join('\n'); + + expect(output).toContain('cd my-app'); + expect(output).toContain('npm install'); + expect(output).toContain('npm run dev'); + }); + + it('should mirror the invoking package manager from npm_config_user_agent', () => { + process.env.npm_config_user_agent = + 'pnpm/9.12.0 npm/? node/v22.0.0 darwin arm64'; + displaySuccess('my-app'); - // Check for pnpm commands - expect(output).toContain('cd my-app'); - expect(output).toContain('pnpm install'); - expect(output).toContain('pnpm dev'); + const calls = (console.log as jest.MockedFunction) + .mock.calls; + const output = calls.map(call => call.join(' ')).join('\n'); + + expect(output).toContain('cd my-app'); + expect(output).toContain('pnpm install'); + expect(output).toContain('pnpm run dev'); + }); }); it('should use chalk for formatting', () => { diff --git a/create-bestax/src/__tests__/file-system.test.ts b/create-bestax/src/__tests__/file-system.test.ts index af48be8a8..82e759062 100644 --- a/create-bestax/src/__tests__/file-system.test.ts +++ b/create-bestax/src/__tests__/file-system.test.ts @@ -17,6 +17,7 @@ jest.unstable_mockModule('fs-extra', () => ({ emptyDir: jest.fn(), ensureDir: jest.fn(), copy: jest.fn(), + move: jest.fn(), readJson: jest.fn(), writeJson: jest.fn(), readFile: jest.fn(), @@ -27,6 +28,7 @@ jest.unstable_mockModule('fs-extra', () => ({ emptyDir: jest.fn(), ensureDir: jest.fn(), copy: jest.fn(), + move: jest.fn(), readJson: jest.fn(), writeJson: jest.fn(), readFile: jest.fn(), @@ -159,6 +161,47 @@ describe('file-system', () => { expect(fs.default.copy).not.toHaveBeenCalled(); }); + + it('should rename _gitignore to .gitignore when the placeholder was copied', async () => { + // Call 1: the source-directory existence check; call 2: the copied + // placeholder in the destination. + (fs.default.existsSync as jest.MockedFunction) + .mockReturnValueOnce(true) + .mockReturnValueOnce(true); + + await copyDirectory('/source', '/target', { _gitignore: '.gitignore' }); + + expect(fs.default.copy).toHaveBeenCalledWith('/source', '/target'); + expect(fs.default.move).toHaveBeenCalledWith( + _path.join('/target', '_gitignore'), + _path.join('/target', '.gitignore'), + { overwrite: true } + ); + }); + + it('should skip the rename when the placeholder is absent from the destination', async () => { + (fs.default.existsSync as jest.MockedFunction) + .mockReturnValueOnce(true) + .mockReturnValueOnce(false); + + await copyDirectory('/source', '/target', { _gitignore: '.gitignore' }); + + expect(fs.default.copy).toHaveBeenCalledWith('/source', '/target'); + expect(fs.default.move).not.toHaveBeenCalled(); + }); + + it('should not run a rename pass when renames is omitted', async () => { + ( + fs.default.existsSync as jest.MockedFunction + ).mockReturnValue(true); + + await copyDirectory('/source', '/target'); + + expect(fs.default.copy).toHaveBeenCalledWith('/source', '/target'); + expect(fs.default.move).not.toHaveBeenCalled(); + // Only the source-directory check ran — no per-entry destination checks. + expect(fs.default.existsSync).toHaveBeenCalledTimes(1); + }); }); describe('updatePackageJson', () => { diff --git a/create-bestax/src/__tests__/package-manager.test.ts b/create-bestax/src/__tests__/package-manager.test.ts new file mode 100644 index 000000000..15ef404fe --- /dev/null +++ b/create-bestax/src/__tests__/package-manager.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { detectPackageManager } from '../package-manager.js'; + +describe('detectPackageManager', () => { + // Jest itself runs under a package manager, so npm_config_user_agent is + // live in this process. Clear it so every case is deterministic — + // an explicit `undefined` argument falls through to the env default. + const originalUserAgent = process.env.npm_config_user_agent; + + beforeEach(() => { + delete process.env.npm_config_user_agent; + }); + + afterEach(() => { + if (originalUserAgent === undefined) { + delete process.env.npm_config_user_agent; + } else { + process.env.npm_config_user_agent = originalUserAgent; + } + }); + + describe('with an explicit user agent argument', () => { + it('detects pnpm', () => { + expect( + detectPackageManager('pnpm/9.12.0 npm/? node/v22.0.0 darwin arm64') + ).toBe('pnpm'); + }); + + it('detects yarn', () => { + expect( + detectPackageManager('yarn/4.5.0 npm/? node/v22.0.0 darwin arm64') + ).toBe('yarn'); + }); + + it('detects bun', () => { + expect( + detectPackageManager('bun/1.1.30 npm/? node/v22.0.0 darwin arm64') + ).toBe('bun'); + }); + + it('detects npm', () => { + expect(detectPackageManager('npm/10.9.0 node/v22.0.0 darwin arm64')).toBe( + 'npm' + ); + }); + + it('falls back to npm when the user agent is undefined', () => { + expect(detectPackageManager(undefined)).toBe('npm'); + }); + + it('falls back to npm when the user agent is empty', () => { + expect(detectPackageManager('')).toBe('npm'); + }); + + it('falls back to npm for an unknown package manager', () => { + expect(detectPackageManager('deno/2.0')).toBe('npm'); + }); + }); + + describe('with the default process.env source', () => { + it('reads npm_config_user_agent when no argument is given', () => { + process.env.npm_config_user_agent = + 'pnpm/9.12.0 npm/? node/v22.0.0 darwin arm64'; + expect(detectPackageManager()).toBe('pnpm'); + }); + }); +}); diff --git a/create-bestax/src/__tests__/project-creator.test.ts b/create-bestax/src/__tests__/project-creator.test.ts index 23ba7358c..fde034e20 100644 --- a/create-bestax/src/__tests__/project-creator.test.ts +++ b/create-bestax/src/__tests__/project-creator.test.ts @@ -242,7 +242,8 @@ describe('ProjectCreator', () => { expect(fileSystem.ensureDirectory).toHaveBeenCalledWith('/target/path'); expect(fileSystem.copyDirectory).toHaveBeenCalledWith( expect.stringContaining('vite'), - '/target/path' + '/target/path', + { _gitignore: '.gitignore' } ); }); }); diff --git a/create-bestax/src/constants.ts b/create-bestax/src/constants.ts index fdc22c2b7..0da648b1f 100644 --- a/create-bestax/src/constants.ts +++ b/create-bestax/src/constants.ts @@ -219,7 +219,9 @@ Read skill \`references/\` files with absolute paths — the shell's cwd is not \`.claude/launch.json\` declares this app's dev server for Claude Code's browser preview (\`npm run dev\` on port 5173, \`--strictPort\`) — start it from there rather than rediscovering -the command. +the command. \`--strictPort\` failing because 5173 is busy means an orphaned dev server from an +earlier session owns the port — kill the listener (\`lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill\`) +and relaunch; don't move the app to another port. ## Docs diff --git a/create-bestax/src/display.ts b/create-bestax/src/display.ts index e1fe9b762..16de5025e 100644 --- a/create-bestax/src/display.ts +++ b/create-bestax/src/display.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import figures from 'figures'; import { MESSAGES } from './constants.js'; +import { detectPackageManager } from './package-manager.js'; export function displayHeader(): void { console.log(); @@ -16,9 +17,12 @@ export function displaySuccess(targetDir: string): void { console.log(); console.log(chalk.bold(MESSAGES.NEXT_STEPS)); console.log(); + // Mirror the package manager that invoked the CLI — ` install` and + // ` run dev` are valid for all four supported PMs. + const pm = detectPackageManager(); console.log(chalk.cyan(` cd ${targetDir}`)); - console.log(chalk.cyan(' pnpm install')); - console.log(chalk.cyan(' pnpm dev')); + console.log(chalk.cyan(` ${pm} install`)); + console.log(chalk.cyan(` ${pm} run dev`)); console.log(); console.log( chalk.gray(' Your app includes a beautiful logo-centric homepage') diff --git a/create-bestax/src/file-system.ts b/create-bestax/src/file-system.ts index 4db52ea29..8cdbe36fe 100644 --- a/create-bestax/src/file-system.ts +++ b/create-bestax/src/file-system.ts @@ -26,12 +26,23 @@ export async function ensureDirectory(targetPath: string): Promise { export async function copyDirectory( source: string, - destination: string + destination: string, + renames?: Record ): Promise { if (!(await checkDirectoryExists(source))) { throw new Error(MESSAGES.TEMPLATE_NOT_FOUND(source)); } await fs.copy(source, destination); + if (renames) { + for (const [from, to] of Object.entries(renames)) { + const copiedPath = path.join(destination, from); + if (fs.existsSync(copiedPath)) { + await fs.move(copiedPath, path.join(destination, to), { + overwrite: true, + }); + } + } + } } export async function readJsonFile(filePath: string): Promise { diff --git a/create-bestax/src/package-manager.ts b/create-bestax/src/package-manager.ts new file mode 100644 index 000000000..d26df4169 --- /dev/null +++ b/create-bestax/src/package-manager.ts @@ -0,0 +1,14 @@ +export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun'; + +const KNOWN = ['npm', 'pnpm', 'yarn', 'bun'] as const; + +/** Detect the package manager that invoked the CLI from npm_config_user_agent + * (e.g. "pnpm/9.12.0 npm/? node/v22 darwin arm64"). Falls back to npm. */ +export function detectPackageManager( + userAgent: string | undefined = process.env.npm_config_user_agent +): PackageManager { + const name = userAgent?.split('/')[0] ?? ''; + return (KNOWN as readonly string[]).includes(name) + ? (name as PackageManager) + : 'npm'; +} diff --git a/create-bestax/src/project-creator.ts b/create-bestax/src/project-creator.ts index fad7fd7e7..d8ce71532 100644 --- a/create-bestax/src/project-creator.ts +++ b/create-bestax/src/project-creator.ts @@ -37,6 +37,13 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); +// npm strips literal `.gitignore` files from published tarballs, so the +// templates track `_gitignore` instead and the scaffolder renames it back +// on copy — otherwise published scaffolds would ship with no .gitignore. +const TEMPLATE_RENAMES: Record = { + _gitignore: '.gitignore', +}; + export interface ProjectConfig { projectName: string; template: string; @@ -101,7 +108,7 @@ export class ProjectCreator { async copyTemplate(template: string, targetPath: string): Promise { const templatePath = this.getTemplatePath(template); await ensureDirectory(targetPath); - await copyDirectory(templatePath, targetPath); + await copyDirectory(templatePath, targetPath, TEMPLATE_RENAMES); } async updateIndexHtmlTitle( diff --git a/create-bestax/templates/vite-ts/.gitignore b/create-bestax/templates/vite-ts/_gitignore similarity index 92% rename from create-bestax/templates/vite-ts/.gitignore rename to create-bestax/templates/vite-ts/_gitignore index 54f07af58..b6cdf575d 100644 --- a/create-bestax/templates/vite-ts/.gitignore +++ b/create-bestax/templates/vite-ts/_gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Editor directories and files .vscode/* @@ -21,4 +22,4 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? diff --git a/create-bestax/templates/vite-ts/tsconfig.json b/create-bestax/templates/vite-ts/tsconfig.json index 7a7611e4a..eeab595c5 100644 --- a/create-bestax/templates/vite-ts/tsconfig.json +++ b/create-bestax/templates/vite-ts/tsconfig.json @@ -5,6 +5,7 @@ "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo", /* Bundler mode */ "moduleResolution": "bundler", diff --git a/create-bestax/templates/vite/.gitignore b/create-bestax/templates/vite/_gitignore similarity index 92% rename from create-bestax/templates/vite/.gitignore rename to create-bestax/templates/vite/_gitignore index 54f07af58..b6cdf575d 100644 --- a/create-bestax/templates/vite/.gitignore +++ b/create-bestax/templates/vite/_gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +*.tsbuildinfo # Editor directories and files .vscode/* @@ -21,4 +22,4 @@ dist-ssr *.ntvs* *.njsproj *.sln -*.sw? \ No newline at end of file +*.sw? diff --git a/docs/docs/guides/getting-started/ai-development.md b/docs/docs/guides/getting-started/ai-development.md index ca2cd637d..b7a3b1310 100644 --- a/docs/docs/guides/getting-started/ai-development.md +++ b/docs/docs/guides/getting-started/ai-development.md @@ -205,4 +205,6 @@ New apps can start AI-ready too: accepting the AI-skills prompt in `npm create b installs the bestax Agent Skills and a `CLAUDE.md`, plus a `.claude/launch.json` that tells Claude Code's browser preview how to start the app's dev server (`npm run dev` on port 5173, with `--strictPort` so a busy port fails loudly instead of silently drifting to another port). -See the [LLMs guide](/docs/guides/llms) for the full AI tooling story. +If 5173 is already taken, the usual cause is an orphaned dev server from an earlier session; +the generated CLAUDE.md tells agents to kill the listener +(`lsof -tiTCP:5173 -sTCP:LISTEN | xargs kill`) rather than move ports. See the [LLMs guide](/docs/guides/llms) for the full AI tooling story.