@drop-in/new
Version: 
A SvelteKit Svelte 5 PocketBase Starter Kit with a CLI
317 lines (275 loc) • 9.28 kB
JavaScript
/**
 * @fileoverview This script sets up a new project from a template.
 */
import { fileURLToPath } from 'node:url';
import * as p from '@clack/prompts';
import { bold, cyan, grey } from 'kleur/colors';
import path from 'node:path';
import fs from 'node:fs';
/**
 * Reads the version from package.json
 * @type {string}
 */
/** @type {{ name?: string, version?: string, dependencies?: Record<string, string>, devDependencies?: Record<string, string> }} */
const package_json_content = JSON.parse(fs.readFileSync(new URL('package.json', import.meta.url), 'utf-8'));
const version = package_json_content.version;
/**
 * Project name from CLI argument or user prompt.
 * @type {string | null }
 */
let app_name = process.argv[2] || null;
console.log(`
${grey(`@drop-in/new version ${version}`)}`);
p.intro('Welcome to 🛹 Drop In 🛹');
// Get project name if not provided
if (!app_name) {
	const inputCwd = await p.text({
		message: 'Enter project name:',
	});
	if (typeof inputCwd !== 'string') {
		// Handle cancellation or unexpected input
		console.error('Invalid project name input.');
		p.cancel('Operation cancelled.');
		process.exit(1);
	}
	app_name = to_valid_package_name(inputCwd);
}
// Check if directory exists and is empty
if (fs.existsSync(app_name)) {
	if (fs.readdirSync(app_name).length > 0) {
		const force = await p.confirm({
			message: 'Directory not empty. Continue?',
			initialValue: false,
		});
		if (force !== true) {
			process.exit(1);
		}
	}
}
/**
 * Constructs a file path relative to the script's distribution directory.
 * @param {string} filePath - The path relative to the distribution directory.
 * @returns {string} The absolute file path.
 */
export function dist(filePath) {
	return fileURLToPath(new URL(`./${filePath}`, import.meta.url));
}
/**
 * Detects and returns the package manager in use (e.g., 'npm', 'yarn', 'pnpm').
 * @returns {string} The detected package manager or 'npm' if not found.
 */
export const package_manager = get_package_manager() || 'npm';
/**
 * Gets the package manager from the environment.
 * @returns {string|undefined}
 */
function get_package_manager() {
	if (!process.env.npm_config_user_agent) {
		return undefined;
	}
	const user_agent = process.env.npm_config_user_agent;
	const pm_spec = user_agent.split(' ')[0];
	const separator_pos = pm_spec.lastIndexOf('/');
	const name = pm_spec.substring(0, separator_pos);
	return name === 'npminstall' ? 'cnpm' : name;
}
/**
 * Prompts the user to choose a template.
 */
const options = await p.group(
	{
		template: () =>
			p.select({
				message: 'Which template?',
				options: fs.readdirSync(dist('templates')).map((dir) => {
					const meta_file = dist(`templates/${dir}/.meta.json`);
					const { title, description } = JSON.parse(fs.readFileSync(meta_file, 'utf8'));
					return {
						label: title,
						hint: description,
						value: dir,
					};
				}),
			}),
	},
	{ onCancel: () => process.exit(1) },
);
/**
 * Converts a string to a valid package name.
 * @param {string} name - The input string.
 * @returns {string} The sanitized package name.
 */
function to_valid_package_name(name) {
	return name
		.trim()
		.toLowerCase()
		.replace(/\s+/g, '-')
		.replace(/^[._]/, '')
		.replace(/[^a-z0-9~.-]+/g, '-');
}
/**
 * Gets versions of packages within the local /packages directory.
 * @returns {Record<string, string>} A map of package names to versions.
 */
function get_local_package_versions() {
	const packagesDir = dist('packages');
	if (!fs.existsSync(packagesDir)) {
		console.warn('Local packages directory not found, cannot determine local package versions.');
		return {};
	}
	/** @type {Record<string, string>} */
	const versions = {};
	const packageSubDirs = fs.readdirSync(packagesDir, { withFileTypes: true })
		.filter(dirent => dirent.isDirectory())
		.map(dirent => dirent.name);
	for (const dirName of packageSubDirs) {
		const packageJsonPath = path.join(packagesDir, dirName, 'package.json');
		if (fs.existsSync(packageJsonPath)) {
			try {
				const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
				if (packageJson.name && packageJson.version) {
					versions[packageJson.name] = packageJson.version;
				}
			} catch (err) {
				// Type guard for error message
				const message = err instanceof Error ? err.message : String(err);
				console.warn(`Failed to parse package.json in ${dirName}: ${message}`);
			}
		}
	}
	return versions;
}
const local_package_versions = get_local_package_versions();
/**
 * Updates the package.json file in the target directory.
 * Sets the package name and updates @drop-in/* dependency versions.
 * @param {string} cwd - Current working directory.
 * @param {string} newName - The new package name.
 * @param {Record<string, string>} root_versions - Versions from the root package.json.
 */
function update_package_json(cwd, newName, root_versions) {
	const packageJsonPath = path.join(cwd, 'package.json');
	if (!fs.existsSync(packageJsonPath)) {
		console.warn(`Target package.json not found at ${packageJsonPath}`);
		return;
	}
	const packageJson = fs.readFileSync(packageJsonPath, 'utf8');
	const packageData = JSON.parse(packageJson);
	// Set package name
	packageData.name = to_valid_package_name(newName);
	// Update @drop-in dependencies
	const depTypes = ['dependencies', 'devDependencies'];
	depTypes.forEach(depType => {
		if (packageData[depType]) {
			for (const dep in packageData[depType]) {
				if (dep.startsWith('@drop-in/') && root_versions[dep]) {
					packageData[depType][dep] = root_versions[dep];
				}
			}
		}
	});
	const updatedPackageJson = JSON.stringify(packageData, null, 2);
	fs.writeFileSync(packageJsonPath, updatedPackageJson, 'utf8');
}
/**
 * Replaces the "app_name" setting in the settings.ts file.
 * @param {string} cwd - Current working directory.
 * @param {string} newName - The new app name.
 */
function replaceAppName(cwd, newName) {
	const settingsFile = path.join(cwd, 'src', 'settings.ts');
	if (fs.existsSync(settingsFile)) {
		const settingsContent = fs.readFileSync(settingsFile, 'utf8');
		const updatedSettingsContent = settingsContent.replace(
			/"app_name":\s*".*?"/,
			`"app_name": "${newName}"`,
		);
		fs.writeFileSync(settingsFile, updatedSettingsContent, 'utf8');
	}
}
/**
 * Writes template files to the new project directory.
 * @param {string} template - The chosen template directory.
 * @param {string} name - The new project name.
 * @param {string} cwd - Current working directory.
 */
function write_template_files(template, name, cwd) {
	const templateDir = dist(`templates/${template}`);
	// const templatePackageJsonPath = path.join(templateDir, 'package.json');
	// Copy template files and example env file
	copy(templateDir, cwd);
	copy(path.join(templateDir, 'example.env'), path.join(cwd, '.env'));
	// Read template's package.json
	// const templatePackageJsonPath = path.join(templateDir, 'package.json');
	// const templatePackageJson = JSON.parse(fs.readFileSync(templatePackageJsonPath, 'utf8'));
	// Update package.json name and dependency versions
	update_package_json(cwd, name, local_package_versions);
	// Update app_name in settings.ts if it exists
	replaceAppName(cwd, name);
}
/**
 * Creates a directory if it doesn't exist.
 * @param {string} dirPath - The directory path to create.
 */
export function mkdirp(dirPath) {
	try {
		fs.mkdirSync(dirPath, { recursive: true });
	} catch (e) {
		if (/** @type {any} */ (e).code === 'EEXIST') return;
		throw e;
	}
}
/**
 * Recursively copies a directory or file.
 * @param {string} from - The source path.
 * @param {string} to - The destination path.
 */
function copy(from, to) {
	const stats = fs.statSync(from);
	if (stats.isDirectory()) {
		fs.readdirSync(from, { withFileTypes: true }).forEach((dirent) => {
			if (dirent.name !== 'node_modules') {
				const sourcePath = path.join(from, dirent.name);
				let destPath = path.join(to, dirent.name);
				// Special handling for .gitignore.txt
				if (dirent.name === '.gitignore.txt') {
					destPath = path.join(to, '.gitignore');
				}
				copy(sourcePath, destPath);
			}
		});
	} else {
		mkdirp(path.dirname(to));
		fs.copyFileSync(from, to);
	}
}
/**
 * Creates the new project.
 * @param {string} cwd - The project directory.
 * @param {object} options - Project creation options.
 * @param {string} options.name - Project name.
 * @param {string} options.template - The chosen template.
 */
async function create(cwd, options) {
	mkdirp(cwd);
	write_template_files(options.template, options.name, cwd);
}
// Create the project
await create(app_name, {
	name: path.basename(path.resolve(app_name)),
	template: options.template,
});
p.outro('Sick, T2D - time to dev.');
// Display post-creation instructions
let i = 1;
const relative = path.relative(process.cwd(), app_name);
if (relative !== '') {
	console.log(`  ${i++}: ${bold(cyan(`cd ${relative}`))}`);
}
console.log(`  ${i++}: ${bold(cyan(`${package_manager} install`))}`);
console.log(
	`  ${i++}: ${bold(cyan('git init && git add -A && git commit -m "Initial commit"'))} (optional)`,
);
console.log(`  ${i++}: ${bold(cyan(`${package_manager} run dev -- --open`))}`);
console.log(`\nTo close the dev server, hit ${bold(cyan('Ctrl-C'))}`);