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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions create-bestax/e2e/utils/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,13 @@ export async function scaffoldApp(config: ScaffoldConfig): Promise<string> {
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.
Expand Down
12 changes: 12 additions & 0 deletions create-bestax/src/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
47 changes: 38 additions & 9 deletions create-bestax/src/__tests__/display.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof console.log>)
.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<typeof console.log>)
.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<typeof console.log>)
.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', () => {
Expand Down
43 changes: 43 additions & 0 deletions create-bestax/src/__tests__/file-system.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
Expand Down Expand Up @@ -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<typeof fs.existsSync>)
.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<typeof fs.existsSync>)
.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<typeof fs.existsSync>
).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', () => {
Expand Down
67 changes: 67 additions & 0 deletions create-bestax/src/__tests__/package-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
3 changes: 2 additions & 1 deletion create-bestax/src/__tests__/project-creator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
);
});
});
Expand Down
4 changes: 3 additions & 1 deletion create-bestax/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 6 additions & 2 deletions create-bestax/src/display.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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 — `<pm> install` and
// `<pm> 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')
Expand Down
13 changes: 12 additions & 1 deletion create-bestax/src/file-system.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,23 @@ export async function ensureDirectory(targetPath: string): Promise<void> {

export async function copyDirectory(
source: string,
destination: string
destination: string,
renames?: Record<string, string>
): Promise<void> {
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<T = unknown>(filePath: string): Promise<T> {
Expand Down
14 changes: 14 additions & 0 deletions create-bestax/src/package-manager.ts
Original file line number Diff line number Diff line change
@@ -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';
}
9 changes: 8 additions & 1 deletion create-bestax/src/project-creator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
_gitignore: '.gitignore',
};

export interface ProjectConfig {
projectName: string;
template: string;
Expand Down Expand Up @@ -101,7 +108,7 @@ export class ProjectCreator {
async copyTemplate(template: string, targetPath: string): Promise<void> {
const templatePath = this.getTemplatePath(template);
await ensureDirectory(targetPath);
await copyDirectory(templatePath, targetPath);
await copyDirectory(templatePath, targetPath, TEMPLATE_RENAMES);
}

async updateIndexHtmlTitle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
*.tsbuildinfo

# Editor directories and files
.vscode/*
Expand All @@ -21,4 +22,4 @@ dist-ssr
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
1 change: 1 addition & 0 deletions create-bestax/templates/vite-ts/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.tsbuildinfo",

/* Bundler mode */
"moduleResolution": "bundler",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ node_modules
dist
dist-ssr
*.local
*.tsbuildinfo

# Editor directories and files
.vscode/*
Expand All @@ -21,4 +22,4 @@ dist-ssr
*.ntvs*
*.njsproj
*.sln
*.sw?
*.sw?
4 changes: 3 additions & 1 deletion docs/docs/guides/getting-started/ai-development.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Loading