create-super-react
Version:
Full‑stack React starter: Vite + TS + Tailwind, Bun/Hono + SQLite, cookie auth, CSRF, and Google OAuth—scaffolded in one command.
386 lines (332 loc) • 15.2 kB
JavaScript
import { execSync } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import * as p from '@clack/prompts';
import pc from 'picocolors';
import { applyTemplateLayer, getClaudeMdContent, setProgressCallback } from "./lib/templates.js";
const run = (cmd, opts = {}) => execSync(cmd, { stdio: "inherit", ...opts });
const tryRun = (cmd, opts = {}) => {
try { execSync(cmd, { stdio: "inherit", ...opts }); return true; }
catch { return false; }
};
// ----------------------------- helpers -------------------------------------
async function ensureTool(cmd, versionArg = "--version", hint = "") {
try { execSync(`${cmd} ${versionArg}`, { stdio: "ignore" }); }
catch {
p.cancel(`Missing tool: ${cmd}. ${hint}`);
process.exit(1);
}
}
async function exists(p) { try { await fs.access(p); return true; } catch { return false; } }
async function dirIsEmpty(dir) { try { const e = await fs.readdir(dir); return e.length === 0; } catch { return true; } }
async function findFile(dir, names) {
for (const n of names) {
const full = path.join(dir, n);
if (await exists(full)) return full;
}
return null;
}
/** Add Tailwind plugin import + plugins: [tailwindcss()] to vite.config.* */
function addTailwindToViteConfig(src) {
let out = src;
if (!/['"]@tailwindcss\/vite['"]/.test(out)) {
out = `import tailwindcss from '@tailwindcss/vite'\n` + out;
}
const pluginsRe = /plugins\s*:\s*\[([\s\S]*?)\]/m;
if (pluginsRe.test(out)) {
if (!/tailwindcss\(\)/.test(out)) {
out = out.replace(pluginsRe, (m, inner) => {
const trimmed = inner.trim();
return `plugins: [${trimmed ? trimmed + ", " : ""}tailwindcss()]`;
});
}
return out;
}
const defineRe = /defineConfig\(\s*\{([\s\S]*?)\}\s*\)/m;
if (defineRe.test(out)) {
const candidate = out.replace(defineRe, (m, inner) => {
if (/plugins\s*:/.test(inner)) return m;
return `defineConfig({\n plugins: [tailwindcss()],\n${inner}\n})`;
});
const count = (candidate.match(/plugins\s*:/g) || []).length;
if (count <= (out.match(/plugins\s*:/g) || []).length + 1) return candidate;
}
if (!/defineConfig/.test(out)) out = `import { defineConfig } from 'vite'\n` + out;
out += `\n// Added by create-super-react\nexport default defineConfig({ plugins: [tailwindcss()] })\n`;
return out;
}
/** Add Vite dev proxy to /api → http://localhost:3000 (used when auth enabled) */
function addProxyToViteConfig(src) {
if (/server\s*:\s*\{[\s\S]*proxy\s*:/.test(src)) return src;
let out = src;
const defineRe = /defineConfig\(\s*\{([\s\S]*?)\}\s*\)/m;
if (defineRe.test(out)) {
return out.replace(defineRe, (m, inner) => {
if (/server\s*:\s*\{/.test(inner)) {
return m.replace(/server\s*:\s*\{([\s\S]*?)\}/m, (sm, sInner) => {
if (/proxy\s*:/.test(sInner)) return sm;
const injected = `proxy: { '/api': 'http://localhost:3000' },`;
return `server: { ${injected}\n${sInner} }`;
});
}
return `defineConfig({\n server: { proxy: { '/api': 'http://localhost:3000' } },\n${inner}\n})`;
});
}
if (!/defineConfig/.test(out)) out = `import { defineConfig } from 'vite'\n` + out;
out += `\n// Added by create-super-react\nexport default defineConfig({ server: { proxy: { '/api': 'http://localhost:3000' } } })\n`;
return out;
}
async function writeBackendEnvExample(backendDir) {
const env = `# Google OAuth credentials (required for auth)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
# Frontend origin for CORS and OAuth redirect
FRONTEND_ORIGIN=http://localhost:5173
# OAuth redirect URI (must match Google Console)
OAUTH_REDIRECT_URI=http://localhost:3000/api/auth/google/callback
`;
await fs.writeFile(path.join(backendDir, ".env.example"), env);
}
// ------------------------------ CLI FLAGS ------------------------------------
const args = process.argv.slice(2);
let projectPath = args.find((a) => !a.startsWith("--"));
// Check for non-interactive mode (flags provided)
const hasAuthFlag = args.includes("--no-auth") || args.includes("--minimal") || args.includes("--password-auth");
const livePreview = args.includes("--live-preview");
// ============================= MAIN LOGIC =====================================
async function main() {
console.log();
// ASCII art logo with elegant gradient
console.log();
// Helper to create RGB color
const rgb = (r, g, b) => (text) => `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
// Create a smooth gradient from peach to rose to purple
const gradient = [
{ bolt: rgb(253, 206, 176), text: rgb(253, 206, 176) }, // Top: Peach
{ bolt: rgb(248, 195, 171), text: rgb(248, 195, 171) }, // Peach transition
{ bolt: rgb(242, 185, 166), text: rgb(242, 185, 166) }, // Peach to rose
{ bolt: rgb(237, 175, 162), text: rgb(237, 175, 162) }, // Light rose
{ bolt: rgb(232, 165, 160), text: rgb(232, 165, 160) }, // Rose approaching middle
{ bolt: rgb(222, 155, 159), text: rgb(222, 155, 159) }, // Middle: Rose
{ bolt: rgb(195, 135, 150), text: rgb(195, 135, 150) }, // Rose to purple
{ bolt: rgb(168, 116, 141), text: rgb(168, 116, 141) }, // Purple-rose
{ bolt: rgb(141, 96, 132), text: rgb(141, 96, 132) }, // Light purple
{ bolt: rgb(114, 87, 127), text: rgb(114, 87, 127) }, // Purple approaching bottom
{ bolt: rgb(88, 77, 123), text: rgb(88, 77, 123) } // Bottom: Purple
];
const lines = [
[' ▄███████████▀ ', '▄█████ ██ ██ █████▄ ██████ █████▄'],
[' ▄██████████▀ ', '██ ██ ██ ██ ██ ██ ██ ██'],
[' ▄█████████▀ ', '██████ ██ ██ █████▀ ████ █████▄'],
[' ▄████████▀ ', ' ██ ██ ██ ██ ██ ██ ██'],
[' ▄█████████████▀ ', '█████▀ ██████ ██ ██████ ██ ██'],
[' ▄████████████▀ ', ''],
[' ▄██████▀ ', '█████▄ ██████ ▄████▄ ▄████ ██████'],
[' ▄█████▀ ', '██ ██ ██ ██ ██ ██ ██'],
[' ▄████▀ ', '█████▄ ████ ██▄▄██ ██ ██'],
[' ▄███▀ ', '██ ██ ██ ██ ██ ██ ██'],
[' ▄█▀ ', '██ ██ ██████ ██ ██ ▀████ ██']
];
// Display with subtle animation
lines.forEach((line, index) => {
const color = gradient[index];
const boltText = color.bolt(line[0]);
const titleText = line[1] ? '\x1b[1m' + color.text(line[1]) + '\x1b[0m' : '';
console.log(boltText + titleText);
});
console.log();
console.log(pc.dim(' Full-stack starter with Vite + TypeScript + Auth'));
console.log();
p.intro(pc.cyan('Welcome to create-super-react'));
let withAuth, withPasswordAuth;
// Interactive mode if no auth flags provided
if (!hasAuthFlag) {
// Get project name if not provided
if (!projectPath) {
projectPath = await p.text({
message: 'Project name:',
placeholder: 'my-app',
defaultValue: 'my-app',
validate(value) {
if (!value) return 'Project name is required';
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Project name can only contain lowercase letters, numbers, and hyphens';
}
}
});
if (p.isCancel(projectPath)) {
p.cancel('Operation cancelled');
process.exit(0);
}
}
const authChoice = await p.select({
message: 'Select authentication:',
options: [
{
value: 'google',
label: 'Google OAuth only',
hint: 'Secure authentication with Google, no password management'
},
{
value: 'password',
label: 'Google OAuth + Email/Password',
hint: 'Full authentication with both Google and traditional email/password'
},
{
value: 'none',
label: 'No authentication',
hint: 'Simple app with navigation but no auth (great for demos)'
}
],
});
if (p.isCancel(authChoice)) {
p.cancel('Operation cancelled');
process.exit(0);
}
withAuth = authChoice !== 'none';
withPasswordAuth = authChoice === 'password';
} else {
// Use flags for non-interactive mode
withAuth = !args.includes("--no-auth") && !args.includes("--minimal");
withPasswordAuth = args.includes("--password-auth");
}
const s = p.spinner();
// Ensure tools
s.start('Checking prerequisites');
await ensureTool("git", "--version", "Install Git from https://git-scm.com");
await ensureTool("node", "--version", "Install Node.js from https://nodejs.org");
await ensureTool("npm", "--version", "npm should come with Node.js");
await ensureTool("bun", "--version", "Install Bun from https://bun.sh");
s.stop('Prerequisites checked');
// Get project path
if (!projectPath) {
projectPath = ".";
}
const root = path.resolve(projectPath);
const projectName = path.basename(root);
// Create/check directory
if (projectPath !== ".") {
if (await exists(root)) {
p.cancel(`Directory "${root}" already exists`);
process.exit(1);
}
await fs.mkdir(root, { recursive: true });
} else {
const empty = await dirIsEmpty(root);
if (!empty) {
p.cancel("Current directory is not empty");
process.exit(1);
}
}
p.log.info(`Creating project in ${pc.cyan(root)}`);
// -------- FRONTEND --------
const frontend = path.join(root, "frontend");
s.start('Creating frontend with Vite + React + TypeScript');
run(`npm create vite@latest frontend -- --template react-ts -y`, { cwd: root });
run(`npm i`, { cwd: frontend });
s.message('Installing Tailwind CSS v4');
run(`npm i -D tailwindcss @tailwindcss/vite`, { cwd: frontend });
// Apply base template (frontend only for now)
const templateVars = {
PROJECT_NAME: projectName,
FRONTEND_DIR: "frontend",
BACKEND_DIR: "backend"
};
// Set up progress tracking for template operations
setProgressCallback((current, total, filename) => {
const percentage = Math.round((current / total) * 100);
s.message(`Copying templates [${percentage}%] ${filename}`);
});
await applyTemplateLayer("base", root, templateVars, { skipBackend: true, showProgress: true });
// Strip default App.css
const appCssPath = path.join(frontend, "src", "App.css");
if (await exists(appCssPath)) {
await fs.unlink(appCssPath);
}
// Always install React Router since base template now uses it
s.message('Adding React Router');
run(`npm i react-router-dom`, { cwd: frontend });
if (withAuth) {
if (withPasswordAuth) {
s.message('Adding authentication with password support');
run(`npm i lucide-react`, { cwd: frontend });
await applyTemplateLayer("auth-password", root, templateVars, { skipBackend: true, showProgress: true });
} else {
s.message('Adding Google OAuth authentication');
await applyTemplateLayer("auth-google", root, templateVars, { skipBackend: true, showProgress: true });
}
}
// -------- BACKEND --------
s.message('Creating backend with Bun + Hono');
const backend = path.join(root, "backend");
// First create backend with Hono
let ok = tryRun(`bunx --yes create-hono@latest backend --template bun --install --pm bun`, { cwd: root });
if (!ok) {
console.warn("bun create failed; falling back to npm create hono...");
run(`npm create hono@latest backend -- --template bun --install --pm bun -y`, { cwd: root });
}
// Then apply backend templates (overwrites Hono's index.ts)
await applyTemplateLayer("base", root, templateVars, { skipFrontend: true, showProgress: true });
if (withAuth) {
if (withPasswordAuth) {
s.message('Setting up backend authentication');
run(`bun add zod`, { cwd: backend });
await applyTemplateLayer("auth-password", root, templateVars, { skipFrontend: true, showProgress: true });
} else {
await applyTemplateLayer("auth-google", root, templateVars, { skipFrontend: true, showProgress: true });
}
await writeBackendEnvExample(backend);
}
try { await fs.appendFile(path.join(backend, ".gitignore"), `\n# SQLite database\n/data.sqlite\n`); } catch {}
// -------- TAILWIND CONFIG --------
s.message('Configuring build tools');
const viteCfgPath = await findFile(frontend, ["vite.config.ts", "vite.config.js"]);
if (viteCfgPath) {
let cfg = await fs.readFile(viteCfgPath, "utf8");
cfg = addTailwindToViteConfig(cfg);
if (withAuth) cfg = addProxyToViteConfig(cfg);
await fs.writeFile(viteCfgPath, cfg);
}
// -------- CLAUDE.md --------
const claudeMdContent = await getClaudeMdContent(projectName, withAuth, withPasswordAuth);
await fs.writeFile(path.join(root, "CLAUDE.md"), claudeMdContent);
// -------- GIT INIT --------
const inGitRepo = await exists(path.join(root, ".git"));
if (!inGitRepo) {
s.message('Initializing git repository');
run(`git init`, { cwd: root });
try { run(`git add -A`, { cwd: root }); } catch {}
try { run(`git commit -m "Initial commit from create-super-react"`, { cwd: root }); } catch {}
}
s.stop('Project created successfully!');
// -------- DONE --------
// Show created file structure
const tree = [
pc.cyan(projectName + '/'),
'├── ' + pc.blue('frontend/') + ' ' + pc.dim('(Vite + React + TypeScript + Tailwind)'),
'│ ├── src/',
'│ │ ├── ' + (withAuth ? 'components/' : 'pages/'),
'│ │ ├── ' + (withAuth ? 'contexts/' : 'App.tsx'),
'│ │ └── ' + (withAuth ? 'pages/' : 'index.css'),
'│ └── package.json',
'├── ' + pc.green('backend/') + ' ' + pc.dim('(Bun + Hono + SQLite)'),
'│ ├── index.ts',
'│ ├── package.json',
withAuth ? '│ └── .env.example' : '│ └── ' + pc.dim('(minimal API)'),
'└── CLAUDE.md ' + pc.dim('(AI-friendly docs)')
].filter(Boolean);
p.note(tree.join('\n'), 'Created files');
p.note([
`${pc.bold('Backend:')} cd backend && bun run dev`,
`${pc.bold('Frontend:')} cd frontend && npm run dev`,
'',
withAuth ? `${pc.yellow('⚠')} Configure Google OAuth in backend/.env` : ''
].filter(Boolean).join('\n'), 'Start developing');
p.outro(`${pc.green('✓')} Done!`);
}
main().catch((e) => {
console.error(e);
process.exit(1);
});