tsdx
Version:
Zero-config TypeScript package development
403 lines (391 loc) • 14 kB
JavaScript
import { program } from 'commander';
import pc from 'picocolors';
import fs from 'fs-extra';
import path from 'path';
import { execa, execaCommand } from 'execa';
import ora from 'ora';
import Enquirer from 'enquirer';
import __node_cjsUrl from 'node:url';
import __node_cjsPath from 'node:path';
const __filename$1 = __node_cjsUrl.fileURLToPath(import.meta.url);
const __dirname$1 = __node_cjsPath.dirname(__filename$1);
const { Select, Input } = Enquirer;
// Read package.json for version
const pkgPath = path.resolve(__dirname$1, '../package.json');
const pkg = fs.readJSONSync(pkgPath);
// Path constants
const paths = {
appRoot: process.cwd(),
appPackageJson: path.resolve(process.cwd(), 'package.json'),
appDist: path.resolve(process.cwd(), 'dist'),
appSrc: path.resolve(process.cwd(), 'src')
};
// Template definitions
const templates = {
basic: {
name: 'basic',
description: 'A basic TypeScript library'
},
react: {
name: 'react',
description: 'A React component library'
}
};
// ASCII banner
const banner = `
${pc.cyan('████████╗███████╗██████╗ ██╗ ██╗')}
${pc.cyan('╚══██╔══╝██╔════╝██╔══██╗╚██╗██╔╝')}
${pc.cyan(' ██║ ███████╗██║ ██║ ╚███╔╝ ')}
${pc.cyan(' ██║ ╚════██║██║ ██║ ██╔██╗ ')}
${pc.cyan(' ██║ ███████║██████╔╝██╔╝ ██╗')}
${pc.cyan(' ╚═╝ ╚══════╝╚═════╝ ╚═╝ ╚═╝')}
${pc.dim('Zero-config TypeScript package development')}
${pc.dim('Powered by bunchee, oxlint, vitest')}
`;
program.name('tsdx').description('Zero-config TypeScript package development').version(pkg.version);
// CREATE command
program.command('create <name>').description('Create a new TypeScript package').option('-t, --template <template>', 'Template to use (basic, react)').action(async (name, options)=>{
console.log(banner);
const spinner = ora();
try {
// Check if folder exists
const projectPath = path.resolve(process.cwd(), name);
if (await fs.pathExists(projectPath)) {
console.log(pc.red(`Error: Directory "${name}" already exists`));
process.exit(1);
}
// Select template
let templateName;
if (options.template && options.template in templates) {
templateName = options.template;
} else {
const prompt = new Select({
message: 'Choose a template',
choices: Object.entries(templates).map(([key, val])=>({
name: key,
message: `${key} - ${val.description}`
}))
});
templateName = await prompt.run();
}
spinner.start(`Creating ${pc.green(name)}...`);
// Copy template
const templatePath = path.resolve(__dirname$1, `../templates/${templateName}`);
await fs.copy(templatePath, projectPath);
// Rename gitignore
const gitignorePath = path.resolve(projectPath, 'gitignore');
if (await fs.pathExists(gitignorePath)) {
await fs.move(gitignorePath, path.resolve(projectPath, '.gitignore'));
}
// Get author name
let author = '';
try {
const { stdout } = await execa('git', [
'config',
'--global',
'user.name'
]);
author = stdout.trim();
} catch {
// Ignore if git config fails
}
if (!author) {
spinner.stop();
const authorPrompt = new Input({
message: 'Who is the package author?',
initial: ''
});
author = await authorPrompt.run();
spinner.start();
}
// Update package.json
const pkgJsonPath = path.resolve(projectPath, 'package.json');
const pkgJson = await fs.readJSON(pkgJsonPath);
pkgJson.name = name;
pkgJson.author = author;
await fs.writeJSON(pkgJsonPath, pkgJson, {
spaces: 2
});
// Update LICENSE
const licensePath = path.resolve(projectPath, 'LICENSE');
if (await fs.pathExists(licensePath)) {
let license = await fs.readFile(licensePath, 'utf-8');
license = license.replace(/<year>/g, new Date().getFullYear().toString());
license = license.replace(/<author>/g, author);
await fs.writeFile(licensePath, license);
}
spinner.succeed(`Created ${pc.green(name)}`);
// Install dependencies
spinner.start('Installing dependencies with bun...');
process.chdir(projectPath);
await execa('bun', [
'install'
]);
spinner.succeed('Installed dependencies');
// Success message
console.log(`
${pc.green('Success!')} Created ${pc.cyan(name)} at ${pc.dim(projectPath)}
Inside that directory, you can run:
${pc.cyan('bun run dev')} Start the dev server
${pc.cyan('bun run build')} Build for production
${pc.cyan('bun run test')} Run tests
${pc.cyan('bun run lint')} Lint the codebase
${pc.cyan('bun run format')} Format the codebase
We suggest that you begin by typing:
${pc.cyan('cd')} ${name}
${pc.cyan('bun run dev')}
`);
} catch (error) {
spinner.fail('Failed to create project');
console.error(error);
process.exit(1);
}
});
// BUILD command
program.command('build').description('Build the package for production').option('--no-clean', 'Skip cleaning the dist folder').action(async (options)=>{
const spinner = ora();
try {
if (options.clean) {
spinner.start('Cleaning dist folder...');
await fs.remove(paths.appDist);
spinner.succeed('Cleaned dist folder');
}
spinner.start('Building with bunchee...');
await execaCommand('bunchee', {
stdio: 'inherit'
});
spinner.succeed('Build complete');
} catch (error) {
spinner.fail('Build failed');
console.error(error);
process.exit(1);
}
});
// DEV/WATCH command
program.command('dev').alias('watch').description('Start development mode with watch').action(async ()=>{
console.log(pc.cyan('Starting development mode...'));
try {
await execaCommand('bunchee --watch', {
stdio: 'inherit'
});
} catch {
console.error(pc.red('Development mode failed'));
process.exit(1);
}
});
// TEST command
program.command('test').description('Run tests with vitest').option('-w, --watch', 'Run in watch mode').option('-c, --coverage', 'Run with coverage').option('-u, --update', 'Update snapshots').allowUnknownOption(true).action(async (options, command)=>{
const args = [
'vitest'
];
if (!options.watch) {
args.push('run');
}
if (options.coverage) {
args.push('--coverage');
}
if (options.update) {
args.push('--update');
}
// Pass through any additional arguments
const extraArgs = command.args || [];
args.push(...extraArgs);
try {
await execa('bunx', args, {
stdio: 'inherit'
});
} catch (error) {
// Vitest exits with non-zero on test failure, which is expected
const exitCode = error.exitCode ?? 1;
process.exit(exitCode);
}
});
// LINT command
program.command('lint').description('Lint the codebase with oxlint').option('-f, --fix', 'Auto-fix fixable issues').option('--config <path>', 'Path to config file').argument('[paths...]', 'Paths to lint', [
'src',
'test'
]).action(async (lintPaths, options)=>{
const args = [
'oxlint'
];
// Filter to existing paths
const existingPaths = lintPaths.filter((p)=>fs.existsSync(path.resolve(process.cwd(), p)));
if (existingPaths.length === 0) {
console.log(pc.yellow('No valid paths to lint'));
return;
}
args.push(...existingPaths);
if (options.fix) {
args.push('--fix');
}
if (options.config) {
args.push('--config', options.config);
}
try {
await execa('bunx', args, {
stdio: 'inherit'
});
console.log(pc.green('Linting complete'));
} catch (error) {
const exitCode = error.exitCode ?? 1;
process.exit(exitCode);
}
});
// FORMAT command
program.command('format').description('Format the codebase with oxfmt').option('-c, --check', 'Check if files are formatted').argument('[paths...]', 'Paths to format', [
'.'
]).action(async (formatPaths, options)=>{
const args = [
'oxfmt'
];
if (options.check) {
args.push('--check');
} else {
args.push('--write');
}
args.push(...formatPaths);
try {
await execa('bunx', args, {
stdio: 'inherit'
});
if (options.check) {
console.log(pc.green('All files are formatted'));
} else {
console.log(pc.green('Formatting complete'));
}
} catch (error) {
const exitCode = error.exitCode ?? 1;
process.exit(exitCode);
}
});
// TYPECHECK command
program.command('typecheck').description('Run TypeScript type checking').option('-w, --watch', 'Run in watch mode').action(async (options)=>{
const args = [
'tsc',
'--noEmit'
];
if (options.watch) {
args.push('--watch');
}
try {
await execa('bunx', args, {
stdio: 'inherit'
});
console.log(pc.green('Type checking complete'));
} catch (error) {
const exitCode = error.exitCode ?? 1;
process.exit(exitCode);
}
});
// INIT command - initialize tsdx in an existing project
program.command('init').description('Initialize tsdx configuration in an existing project').action(async ()=>{
const spinner = ora();
try {
// Check if package.json exists
if (!await fs.pathExists(paths.appPackageJson)) {
console.log(pc.red('No package.json found. Run this command in a project directory.'));
process.exit(1);
}
spinner.start('Initializing tsdx configuration...');
// Read current package.json
const pkgJson = await fs.readJSON(paths.appPackageJson);
// Add/update exports field for bunchee
if (!pkgJson.exports) {
pkgJson.exports = {
'.': {
import: './dist/index.js',
require: './dist/index.cjs',
types: './dist/index.d.ts'
},
'./package.json': './package.json'
};
}
// Add main/module/types
pkgJson.main = './dist/index.cjs';
pkgJson.module = './dist/index.js';
pkgJson.types = './dist/index.d.ts';
pkgJson.type = 'module';
// Add scripts
pkgJson.scripts = {
...pkgJson.scripts,
dev: 'bunchee --watch',
build: 'bunchee',
test: 'vitest run',
'test:watch': 'vitest',
lint: 'oxlint',
format: 'oxfmt --write .',
'format:check': 'oxfmt --check .',
typecheck: 'tsc --noEmit'
};
await fs.writeJSON(paths.appPackageJson, pkgJson, {
spaces: 2
});
// Create tsconfig.json if it doesn't exist
const tsconfigPath = path.resolve(process.cwd(), 'tsconfig.json');
if (!await fs.pathExists(tsconfigPath)) {
const tsconfig = {
compilerOptions: {
target: 'ES2022',
module: 'ESNext',
moduleResolution: 'bundler',
lib: [
'ES2022',
'DOM'
],
strict: true,
esModuleInterop: true,
skipLibCheck: true,
forceConsistentCasingInFileNames: true,
declaration: true,
declarationMap: true,
outDir: './dist',
rootDir: './src',
resolveJsonModule: true,
isolatedModules: true,
noEmit: true
},
include: [
'src'
],
exclude: [
'node_modules',
'dist'
]
};
await fs.writeJSON(tsconfigPath, tsconfig, {
spaces: 2
});
}
// Create vitest.config.ts if it doesn't exist
const vitestConfigPath = path.resolve(process.cwd(), 'vitest.config.ts');
if (!await fs.pathExists(vitestConfigPath)) {
const vitestConfig = `import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
},
});
`;
await fs.writeFile(vitestConfigPath, vitestConfig);
}
spinner.succeed('Initialized tsdx configuration');
console.log(`
${pc.green('Configuration added!')}
Install the required dev dependencies:
${pc.cyan('bun add -D bunchee vitest typescript')}
${pc.cyan('bun add -D oxlint')}
Then you can run:
${pc.cyan('bun run dev')} Start development mode
${pc.cyan('bun run build')} Build for production
${pc.cyan('bun run test')} Run tests
${pc.cyan('bun run lint')} Lint the codebase
`);
} catch (error) {
spinner.fail('Failed to initialize');
console.error(error);
process.exit(1);
}
});
program.parse();