organize code

This commit is contained in:
Aman Varshney
2025-02-20 19:14:28 +05:30
parent 9032a598d0
commit f804a9efda
16 changed files with 451 additions and 282 deletions

View File

@@ -0,0 +1,5 @@
---
"create-better-t-stack": minor
---
add flags and prompt for orm, add none option in database

View File

@@ -9,9 +9,9 @@ export const PKG_ROOT = path.join(distPath, "../");
export const DEFAULT_CONFIG: ProjectConfig = {
projectName: "my-better-t-app",
database: "sqlite",
orm: "drizzle",
auth: true,
features: [],
git: true,
packageManager: "npm",
orm: "drizzle",
};

View File

@@ -52,6 +52,8 @@ export async function createProject(options: ProjectConfig) {
if (options.database === "sqlite") {
await setupTurso(projectDir);
} else if (options.database === "postgres") {
// Handle postgres setup
}
const installDepsResponse = await confirm({

View File

@@ -1,30 +1,12 @@
import path from "node:path";
import {
cancel,
confirm,
group,
intro,
log,
multiselect,
outro,
select,
spinner,
text,
} from "@clack/prompts";
import { cancel, intro, log, outro, spinner } from "@clack/prompts";
import { Command } from "commander";
import fs from "fs-extra";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "./consts";
import { DEFAULT_CONFIG } from "./constants";
import { createProject } from "./helpers/create-project";
import type {
PackageManager,
ProjectConfig,
ProjectDatabase,
ProjectFeature,
ProjectORM,
} from "./types";
import { gatherConfig } from "./prompts/config-prompts";
import type { PackageManager, ProjectConfig, ProjectFeature } from "./types";
import { displayConfig } from "./utils/display-config";
import { generateReproducibleCommand } from "./utils/generate-reproducible-command";
import { getUserPkgManager } from "./utils/get-package-manager";
import { getVersion } from "./utils/get-version";
import { renderTitle } from "./utils/render-title";
@@ -35,215 +17,6 @@ process.on("SIGINT", () => {
const program = new Command();
async function gatherConfig(
flags: Partial<ProjectConfig>,
): Promise<ProjectConfig> {
const result = await group(
{
projectName: async () => {
if (flags.projectName) return flags.projectName;
let isValid = false;
let projectName: string | symbol = "";
let defaultName = DEFAULT_CONFIG.projectName;
let counter = 1;
while (fs.pathExistsSync(path.resolve(process.cwd(), defaultName))) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
counter++;
}
while (!isValid) {
const response = await text({
message: "What is your project named? (directory name or path)",
placeholder: defaultName,
initialValue: flags.projectName,
defaultValue: defaultName,
validate: (value) => {
const nameToUse = value.trim() || defaultName;
const projectDir = path.resolve(process.cwd(), nameToUse);
if (fs.pathExistsSync(projectDir)) {
const dirContents = fs.readdirSync(projectDir);
if (dirContents.length > 0) {
return `Directory "${nameToUse}" already exists and is not empty. Please choose a different name.`;
}
}
isValid = true;
return undefined;
},
});
if (typeof response === "symbol") {
cancel(pc.red("Operation cancelled."));
process.exit(0);
}
projectName = response || defaultName;
}
return projectName as string;
},
database: () =>
flags.database !== undefined
? Promise.resolve(flags.database)
: select<ProjectDatabase>({
message: "Which database would you like to use?",
options: [
{
value: "sqlite",
label: "SQLite",
hint: "by Turso (recommended)",
},
{
value: "postgres",
label: "PostgreSQL",
hint: "Traditional relational database",
},
],
}),
orm: () =>
flags.orm !== undefined
? Promise.resolve(flags.orm)
: select<ProjectORM>({
message: "Which ORM would you like to use?",
options: [
{
value: "drizzle",
label: "Drizzle",
hint: "Type-safe, lightweight ORM (recommended)",
},
// {
// value: "prisma",
// label: "Prisma (coming soon)",
// hint: "Feature-rich ORM with great DX",
// },
],
initialValue: "drizzle",
}),
auth: () =>
flags.auth !== undefined
? Promise.resolve(flags.auth)
: confirm({
message: "Would you like to add authentication with Better-Auth?",
initialValue: DEFAULT_CONFIG.auth,
}),
features: () =>
flags.features !== undefined
? Promise.resolve(flags.features)
: multiselect<ProjectFeature>({
message: "Which features would you like to add?",
options: [
{
value: "docker",
label: "Docker setup",
hint: "Containerize your application",
},
{
value: "github-actions",
label: "GitHub Actions",
hint: "CI/CD workflows",
},
{
value: "SEO",
label: "Basic SEO setup",
hint: "Search engine optimization configuration",
},
],
required: false,
}),
git: () =>
flags.git !== undefined
? Promise.resolve(flags.git)
: confirm({
message: "Initialize a new git repository?",
initialValue: DEFAULT_CONFIG.git,
}),
packageManager: async () => {
if (flags.packageManager !== undefined) {
return flags.packageManager;
}
const detectedPackageManager = getUserPkgManager();
const useDetected = await confirm({
message: `Use ${detectedPackageManager} as your package manager?`,
});
if (useDetected) return detectedPackageManager;
return select<PackageManager>({
message: "Which package manager would you like to use?",
options: [
{ value: "npm", label: "npm", hint: "Node Package Manager" },
{
value: "pnpm",
label: "pnpm",
hint: "Fast, disk space efficient package manager",
},
{
value: "yarn",
label: "yarn",
hint: "Fast, reliable, and secure dependency management",
},
{
value: "bun",
label: "bun",
hint: "All-in-one JavaScript runtime & toolkit (recommended)",
},
],
initialValue: "bun",
});
},
},
{
onCancel: () => {
cancel(pc.red("Operation cancelled."));
process.exit(0);
},
},
);
return {
projectName: result.projectName ?? DEFAULT_CONFIG.projectName,
database: result.database ?? DEFAULT_CONFIG.database,
orm: result.orm ?? DEFAULT_CONFIG.orm,
auth: result.auth ?? DEFAULT_CONFIG.auth,
features: result.features ?? DEFAULT_CONFIG.features,
git: result.git ?? DEFAULT_CONFIG.git,
packageManager: result.packageManager ?? DEFAULT_CONFIG.packageManager,
};
}
function displayConfig(config: Partial<ProjectConfig>) {
const configDisplay = [];
if (config.projectName) {
configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`);
}
if (config.database) {
configDisplay.push(`${pc.blue("Database:")} ${config.database}`);
}
if (config.orm) {
configDisplay.push(`${pc.blue("ORM:")} ${config.orm}`);
}
if (config.auth !== undefined) {
configDisplay.push(`${pc.blue("Authentication:")} ${config.auth}`);
}
if (config.features?.length) {
configDisplay.push(`${pc.blue("Features:")} ${config.features.join(", ")}`);
}
if (config.git !== undefined) {
configDisplay.push(`${pc.blue("Git Init:")} ${config.git}`);
}
if (config.packageManager) {
configDisplay.push(
`${pc.blue("Package Manager:")} ${config.packageManager}`,
);
}
return configDisplay.join("\n");
}
async function main() {
const s = spinner();
try {
@@ -256,6 +29,7 @@ async function main() {
.version(getVersion())
.argument("[project-directory]", "Project name/directory")
.option("-y, --yes", "Use default configuration")
.option("--no-database", "Skip database setup")
.option("--sqlite", "Use SQLite database")
.option("--postgres", "Use PostgreSQL database")
.option("--auth", "Include authentication")
@@ -277,38 +51,28 @@ async function main() {
const projectDirectory = program.args[0];
const flagConfig: Partial<ProjectConfig> = {
projectName: projectDirectory || undefined,
database: options.sqlite
? "sqlite"
: options.postgres
? "postgres"
: undefined,
orm: options.drizzle ? "drizzle" : options.prisma ? "prisma" : undefined,
auth: "auth" in options ? options.auth : undefined,
packageManager: options.npm
? "npm"
: options.pnpm
? "pnpm"
: options.yarn
? "yarn"
: options.bun
? "bun"
: undefined,
git: "git" in options ? options.git : undefined,
features:
options.docker || options.githubActions || options.seo
? ([
...(options.docker ? ["docker"] : []),
...(options.githubActions ? ["github-actions"] : []),
...(options.seo ? ["SEO"] : []),
] as ProjectFeature[])
: undefined,
...(projectDirectory && { projectName: projectDirectory }),
...(options.database === false && { database: "none" }),
...(options.sqlite && { database: "sqlite" }),
...(options.postgres && { database: "postgres" }),
...(options.drizzle && { orm: "drizzle" }),
...(options.prisma && { orm: "prisma" }),
...("auth" in options && { auth: options.auth }),
...(options.npm && { packageManager: "npm" }),
...(options.pnpm && { packageManager: "pnpm" }),
...(options.yarn && { packageManager: "yarn" }),
...(options.bun && { packageManager: "bun" }),
...("git" in options && { git: options.git }),
...((options.docker || options.githubActions || options.seo) && {
features: [
...(options.docker ? ["docker"] : []),
...(options.githubActions ? ["github-actions"] : []),
...(options.seo ? ["SEO"] : []),
] as ProjectFeature[],
}),
};
if (
!options.yes &&
Object.values(flagConfig).some((v) => v !== undefined)
) {
if (!options.yes && Object.keys(flagConfig).length > 0) {
log.info(pc.yellow("Using these pre-selected options:"));
log.message(displayConfig(flagConfig));
log.message("");
@@ -317,23 +81,26 @@ async function main() {
const config = options.yes
? {
...DEFAULT_CONFIG,
yes: true,
projectName: projectDirectory ?? DEFAULT_CONFIG.projectName,
database: options.database ?? DEFAULT_CONFIG.database,
orm: options.drizzle
? "drizzle"
: options.prisma
? "prisma"
: DEFAULT_CONFIG.orm, // Add this line
database:
options.database === false
? "none"
: (options.database ?? DEFAULT_CONFIG.database),
orm:
options.database === false
? "none"
: options.drizzle
? "drizzle"
: options.prisma
? "prisma"
: DEFAULT_CONFIG.orm,
auth: options.auth ?? DEFAULT_CONFIG.auth,
git: options.git ?? DEFAULT_CONFIG.git,
packageManager:
options.packageManager ?? DEFAULT_CONFIG.packageManager,
features: [
...(options.docker ? ["docker"] : []),
...(options.githubActions ? ["github-actions"] : []),
...(options.seo ? ["SEO"] : []),
] as ProjectFeature[],
flagConfig.packageManager ?? DEFAULT_CONFIG.packageManager,
features: flagConfig.features?.length
? flagConfig.features
: DEFAULT_CONFIG.features,
}
: await gatherConfig(flagConfig);

View File

@@ -0,0 +1,23 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
export async function getAuthChoice(
auth: boolean | undefined,
hasDatabase: boolean,
): Promise<boolean> {
if (!hasDatabase) return false;
if (auth !== undefined) return auth;
const response = await confirm({
message: "Would you like to add authentication with Better-Auth?",
initialValue: DEFAULT_CONFIG.auth,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,62 @@
import { cancel, group } from "@clack/prompts";
import pc from "picocolors";
import type {
PackageManager,
ProjectConfig,
ProjectDatabase,
ProjectFeature,
ProjectORM,
} from "../types";
import { getAuthChoice } from "./auth";
import { getDatabaseChoice } from "./database";
import { getFeaturesChoice } from "./features";
import { getGitChoice } from "./git";
import { getORMChoice } from "./orm";
import { getPackageManagerChoice } from "./package-manager";
import { getProjectName } from "./project-name";
interface PromptGroupResults {
projectName: string;
database: ProjectDatabase;
orm: ProjectORM;
auth: boolean;
features: ProjectFeature[];
git: boolean;
packageManager: PackageManager;
}
export async function gatherConfig(
flags: Partial<ProjectConfig>,
): Promise<ProjectConfig> {
const result = await group<PromptGroupResults>(
{
projectName: async () => {
return getProjectName(flags.projectName);
},
database: () => getDatabaseChoice(flags.database),
orm: ({ results }) =>
getORMChoice(flags.orm, results.database !== "none"),
auth: ({ results }) =>
getAuthChoice(flags.auth, results.database !== "none"),
features: () => getFeaturesChoice(flags.features),
git: () => getGitChoice(flags.git),
packageManager: () => getPackageManagerChoice(flags.packageManager),
},
{
onCancel: () => {
cancel(pc.red("Operation cancelled"));
process.exit(0);
},
},
);
return {
projectName: result.projectName,
database: result.database,
orm: result.orm,
auth: result.auth,
features: result.features,
git: result.git,
packageManager: result.packageManager,
};
}

View File

@@ -0,0 +1,38 @@
import { cancel, isCancel, select } from "@clack/prompts";
import pc from "picocolors";
import type { ProjectDatabase } from "../types";
export async function getDatabaseChoice(
database?: ProjectDatabase,
): Promise<ProjectDatabase> {
if (database !== undefined) return database;
const response = await select<ProjectDatabase>({
message: "Which database would you like to use?",
options: [
{
value: "none",
label: "None",
hint: "No database setup",
},
{
value: "sqlite",
label: "SQLite",
hint: "by Turso (recommended)",
},
{
value: "postgres",
label: "PostgreSQL",
hint: "Traditional relational database",
},
],
initialValue: "sqlite",
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,38 @@
import { cancel, isCancel, multiselect } from "@clack/prompts";
import pc from "picocolors";
import type { ProjectFeature } from "../types";
export async function getFeaturesChoice(
features?: ProjectFeature[],
): Promise<ProjectFeature[]> {
if (features !== undefined) return features;
const response = await multiselect<ProjectFeature>({
message: "Which features would you like to add?",
options: [
{
value: "docker",
label: "Docker setup",
hint: "Containerize your application",
},
{
value: "github-actions",
label: "GitHub Actions",
hint: "CI/CD workflows",
},
{
value: "SEO",
label: "Basic SEO setup",
hint: "Search engine optimization configuration",
},
],
required: false,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,19 @@
import { cancel, confirm, isCancel } from "@clack/prompts";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
export async function getGitChoice(git?: boolean): Promise<boolean> {
if (git !== undefined) return git;
const response = await confirm({
message: "Initialize a new git repository?",
initialValue: DEFAULT_CONFIG.git,
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,30 @@
import { cancel, isCancel, select } from "@clack/prompts";
import pc from "picocolors";
import type { ProjectORM } from "../types";
export async function getORMChoice(
orm: ProjectORM | undefined,
hasDatabase: boolean,
): Promise<ProjectORM> {
if (!hasDatabase) return "none";
if (orm !== undefined) return orm;
const response = await select<ProjectORM>({
message: "Which ORM would you like to use?",
options: [
{
value: "drizzle",
label: "Drizzle",
hint: "Type-safe, lightweight ORM (recommended)",
},
],
initialValue: "drizzle",
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,52 @@
import { cancel, confirm, isCancel, select } from "@clack/prompts";
import pc from "picocolors";
import type { PackageManager } from "../types";
import { getUserPkgManager } from "../utils/get-package-manager";
export async function getPackageManagerChoice(
packageManager?: PackageManager,
): Promise<PackageManager> {
if (packageManager !== undefined) return packageManager;
const detectedPackageManager = getUserPkgManager();
const useDetected = await confirm({
message: `Use ${detectedPackageManager} as your package manager?`,
});
if (isCancel(useDetected)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
if (useDetected) return detectedPackageManager;
const response = await select<PackageManager>({
message: "Which package manager would you like to use?",
options: [
{ value: "npm", label: "npm", hint: "Node Package Manager" },
{
value: "pnpm",
label: "pnpm",
hint: "Fast, disk space efficient package manager",
},
{
value: "yarn",
label: "yarn",
hint: "Fast, reliable, and secure dependency management",
},
{
value: "bun",
label: "bun",
hint: "All-in-one JavaScript runtime & toolkit (recommended)",
},
],
initialValue: "bun",
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled"));
process.exit(0);
}
return response;
}

View File

@@ -0,0 +1,95 @@
import path from "node:path";
import { cancel, isCancel, text } from "@clack/prompts";
import fs from "fs-extra";
import pc from "picocolors";
import { DEFAULT_CONFIG } from "../constants";
const INVALID_CHARS = ["<", ">", ":", '"', "|", "?", "*"];
const MAX_LENGTH = 255;
function validateDirectoryName(name: string): string | undefined {
if (!name) return "Project name cannot be empty";
if (name.length > MAX_LENGTH) {
return `Project name must be less than ${MAX_LENGTH} characters`;
}
if (INVALID_CHARS.some((char) => name.includes(char))) {
return "Project name contains invalid characters";
}
if (name.startsWith(".") || name.startsWith("-")) {
return "Project name cannot start with a dot or dash";
}
if (
name.toLowerCase() === "node_modules" ||
name.toLowerCase() === "favicon.ico"
) {
return "Project name is reserved";
}
return undefined;
}
export async function getProjectName(initialName?: string): Promise<string> {
if (initialName) {
const finalDirName = path.basename(initialName);
const validationError = validateDirectoryName(finalDirName);
if (!validationError) {
const projectDir = path.resolve(process.cwd(), initialName);
if (
!fs.pathExistsSync(projectDir) ||
fs.readdirSync(projectDir).length === 0
) {
return initialName;
}
}
}
let isValid = false;
let projectName = "";
let defaultName = DEFAULT_CONFIG.projectName;
let counter = 1;
while (fs.pathExistsSync(path.resolve(process.cwd(), defaultName))) {
defaultName = `${DEFAULT_CONFIG.projectName}-${counter}`;
counter++;
}
while (!isValid) {
const response = await text({
message: "What is your project named? (directory name or path)",
placeholder: defaultName,
initialValue: initialName,
defaultValue: defaultName,
validate: (value) => {
const nameToUse = value.trim() || defaultName;
const finalDirName = path.basename(nameToUse);
const validationError = validateDirectoryName(finalDirName);
if (validationError) return validationError;
const projectDir = path.resolve(process.cwd(), nameToUse);
if (!projectDir.startsWith(process.cwd())) {
return "Project path must be within current directory";
}
if (fs.pathExistsSync(projectDir)) {
const dirContents = fs.readdirSync(projectDir);
if (dirContents.length > 0) {
return `Directory "${nameToUse}" already exists and is not empty. Please choose a different name.`;
}
}
isValid = true;
return undefined;
},
});
if (isCancel(response)) {
cancel(pc.red("Operation cancelled."));
process.exit(0);
}
projectName = response || defaultName;
}
return projectName;
}

View File

@@ -1,10 +1,10 @@
export type ProjectFeature = "docker" | "github-actions" | "SEO";
export type ProjectDatabase = "sqlite" | "postgres";
export type ProjectDatabase = "sqlite" | "postgres" | "none";
export type PackageManager = "npm" | "yarn" | "pnpm" | "bun";
export type ProjectORM = "drizzle" | "prisma";
export type ProjectORM = "drizzle" | "prisma" | "none";
export type ProjectConfig = {
yes?: boolean;

View File

@@ -0,0 +1,32 @@
import pc from "picocolors";
import type { ProjectConfig } from "../types";
export function displayConfig(config: Partial<ProjectConfig>) {
const configDisplay = [];
if (config.projectName) {
configDisplay.push(`${pc.blue("Project Name:")} ${config.projectName}`);
}
if (config.database) {
configDisplay.push(`${pc.blue("Database:")} ${config.database}`);
}
if (config.orm) {
configDisplay.push(`${pc.blue("ORM:")} ${config.orm}`);
}
if (config.auth !== undefined) {
configDisplay.push(`${pc.blue("Authentication:")} ${config.auth}`);
}
if (config.features?.length) {
configDisplay.push(`${pc.blue("Features:")} ${config.features.join(", ")}`);
}
if (config.git !== undefined) {
configDisplay.push(`${pc.blue("Git Init:")} ${config.git}`);
}
if (config.packageManager) {
configDisplay.push(
`${pc.blue("Package Manager:")} ${config.packageManager}`,
);
}
return configDisplay.join("\n");
}

View File

@@ -1,4 +1,4 @@
import { DEFAULT_CONFIG } from "../consts";
import { DEFAULT_CONFIG } from "../constants";
import type { ProjectConfig } from "../types";
export function generateReproducibleCommand(config: ProjectConfig): string {
@@ -14,11 +14,17 @@ export function generateReproducibleCommand(config: ProjectConfig): string {
flags.push("-y");
}
// Handle database flag
if (config.database !== DEFAULT_CONFIG.database) {
flags.push(config.database === "sqlite" ? "--sqlite" : "--postgres");
if (config.database === "none") {
flags.push("--no-database");
} else {
flags.push(config.database === "sqlite" ? "--sqlite" : "--postgres");
}
}
if (config.orm !== DEFAULT_CONFIG.orm) {
// Handle ORM flag only if database is not "none"
if (config.database !== "none" && config.orm !== DEFAULT_CONFIG.orm) {
flags.push(config.orm === "drizzle" ? "--drizzle" : "--prisma");
}

View File

@@ -1,6 +1,6 @@
import path from "node:path";
import fs from "fs-extra";
import { PKG_ROOT } from "../consts";
import { PKG_ROOT } from "../constants";
export const getVersion = () => {
const packageJsonPath = path.join(PKG_ROOT, "package.json");