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
320 changes: 249 additions & 71 deletions index.js
Original file line number Diff line number Diff line change
@@ -1,66 +1,173 @@
#!/usr/bin/env node
import { Command } from 'commander';
import inquirer from 'inquirer';
import chalk from 'chalk';
import { readFileSync } from 'fs';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { generateProject } from './src/generate.js';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf-8'));

const TEMPLATES = {
portfolio: {
label: "Portfolio Website",
variants: ["Minimal Dark", "Creative Agency", "Developer Folio", "Photography", "Resume Style"],
sidebarOpts: false
label: 'Portfolio Website',
variants: ['Minimal Dark', 'Creative Agency', 'Developer Folio', 'Photography', 'Resume Style'],
sidebarOpts: false,
},
ecommerce: {
label: "E-Commerce Store",
variants: ["Fashion Store", "Electronics Shop", "Food & Grocery", "Digital Products", "Multi-Vendor"],
sidebarOpts: true
label: 'E-Commerce Store',
variants: ['Fashion Store', 'Electronics Shop', 'Food & Grocery', 'Digital Products', 'Multi-Vendor'],
sidebarOpts: true,
},
school: {
label: "School Management",
variants: ["University Portal", "K-12 System", "Online Academy", "Training Platform", "LMS Dashboard"],
sidebarOpts: true
label: 'School Management',
variants: ['University Portal', 'K-12 System', 'Online Academy', 'Training Platform', 'LMS Dashboard'],
sidebarOpts: true,
},
saas: {
label: "SaaS Dashboard",
variants: ["Analytics Tool", "CRM System", "Project Manager", "Finance Tracker", "HR Platform"],
sidebarOpts: true
label: 'SaaS Dashboard',
variants: ['Analytics Tool', 'CRM System', 'Project Manager', 'Finance Tracker', 'HR Platform'],
sidebarOpts: true,
},
blog: {
label: "Blog / Magazine",
variants: ["Tech Blog", "Lifestyle Mag", "News Portal", "Personal Journal", "Tutorial Site"],
sidebarOpts: true
}
label: 'Blog / Magazine',
variants: ['Tech Blog', 'Lifestyle Mag', 'News Portal', 'Personal Journal', 'Tutorial Site'],
sidebarOpts: true,
},
};

const VALID_TEMPLATES = Object.keys(TEMPLATES);
const VALID_ARCHS = ['nextjs-monolith', 'vite-react', 'nextjs-turborepo'];
const ARCH_ALIASES = { nextjs: 'nextjs-monolith', vite: 'vite-react', turborepo: 'nextjs-turborepo' };
const DESIGNS = [
"Minimal Clean", "Dark Terminal", "Glassmorphism", "Brutalist",
"Soft Pastel", "Corporate Blue", "Neon Cyberpunk", "Earth Tones"
'Minimal Clean', 'Dark Terminal', 'Glassmorphism', 'Brutalist',
'Soft Pastel', 'Corporate Blue', 'Neon Cyberpunk', 'Earth Tones',
];

async function runOpusifyWizard() {
function resolveArch(value) {
if (!value) return undefined;
const lower = value.toLowerCase();
if (ARCH_ALIASES[lower]) return ARCH_ALIASES[lower];
if (VALID_ARCHS.includes(lower)) return lower;
return null;
}

function resolveDesign(value) {
if (!value) return undefined;
const lower = value.toLowerCase();
const match = DESIGNS.find((d) => d.toLowerCase() === lower);
return match || null;
}

function printBanner() {
console.clear();
console.log(chalk.green('❯ npx opusify create'));
console.log(chalk.green(' ██████╗ ██████╗ ██╗ ██╗███████╗██╗███████╗██╗ ██╗'));
console.log(chalk.blue(` Welcome to ${chalk.magenta('Opusify')} — The Full-Stack Scaffold Engine v1.0.0`));
console.log(chalk.blue(` Welcome to ${chalk.magenta('Opusify')} — The Full-Stack Scaffold Engine v${pkg.version}`));
console.log(chalk.gray(' Generate production-ready apps with one command.\n'));
}

let config;
try {
config = await inquirer.prompt([
// NEW STEP: Project Name
{
async function createAction(projectName, options) {
printBanner();

// Validate flags early
if (options.template && !VALID_TEMPLATES.includes(options.template)) {
console.log(chalk.red(`\n✖ Invalid template: "${options.template}"`));
console.log(chalk.gray(` Valid options: ${VALID_TEMPLATES.join(', ')}`));
process.exit(1);
}

if (options.arch) {
const resolved = resolveArch(options.arch);
if (resolved === null) {
console.log(chalk.red(`\n✖ Invalid architecture: "${options.arch}"`));
console.log(chalk.gray(` Valid options: nextjs, vite, turborepo (or nextjs-monolith, vite-react, nextjs-turborepo)`));
process.exit(1);
}
options.arch = resolved;
}

if (options.design) {
const resolved = resolveDesign(options.design);
if (resolved === null) {
console.log(chalk.red(`\n✖ Invalid design: "${options.design}"`));
console.log(chalk.gray(` Valid options: ${DESIGNS.join(', ')}`));
process.exit(1);
}
options.design = resolved;
}

if (options.nav !== undefined) {
const nav = parseInt(options.nav, 10);
if (isNaN(nav) || nav < 3 || nav > 9) {
console.log(chalk.red('\n✖ Invalid nav count. Must be a number between 3 and 9.'));
process.exit(1);
}
options.nav = nav;
}

// Build the list of prompts, skipping any that were provided via flags
const prompts = [];
const defaults = {
projectName: projectName || 'my-opusify-app',
template: 'portfolio',
variant: null, // resolved after template is known
architecture: 'nextjs-monolith',
design: 'Minimal Clean',
navCount: 5,
includeSidebar: false,
initGit: true,
enableSecurity: true,
};

// If --yes, use all defaults + any provided flags
if (options.yes) {
const template = options.template || defaults.template;
const config = {
projectName: projectName || defaults.projectName,
template,
variant: options.variant || TEMPLATES[template].variants[0],
architecture: options.arch || defaults.architecture,
design: options.design || defaults.design,
navCount: options.nav || defaults.navCount,
includeSidebar: options.sidebar || defaults.includeSidebar,
initGit: options.git !== false,
enableSecurity: defaults.enableSecurity,
noInstall: options.install === false,
};

console.log(chalk.green('✔ Using defaults (--yes mode)'));
console.log(chalk.gray(` Project: ${config.projectName}`));
console.log(chalk.gray(` Template: ${config.template} / ${config.variant}`));
console.log(chalk.gray(` Architecture: ${config.architecture}`));
console.log(chalk.gray(` Design: ${config.design}`));
console.log('');

await generateProject(config);
return;
}

// Interactive prompts — skip those already provided via flags
if (!projectName) {
prompts.push({
type: 'input',
name: 'projectName',
message: chalk.magenta.bold('What is your project name?'),
default: 'my-opusify-app',
default: defaults.projectName,
validate: (input) => {
if (!/^[a-z0-9-]+$/.test(input)) {
return 'Please use only lowercase letters, numbers, and hyphens (e.g., my-awesome-app)';
}
return true;
}
},
// Step 1: Template
{
},
});
}

if (!options.template) {
prompts.push({
type: 'rawlist',
name: 'template',
message: chalk.magenta.bold('Select a project template:'),
Expand All @@ -69,71 +176,95 @@ async function runOpusifyWizard() {
{ name: 'E-Commerce Store', value: 'ecommerce' },
{ name: 'School Management', value: 'school' },
{ name: 'SaaS Dashboard', value: 'saas' },
{ name: 'Blog / Magazine', value: 'blog' }
]
},
// Step 2: Variant
{
{ name: 'Blog / Magazine', value: 'blog' },
],
});
}

// Variant — skip if provided via flag
if (!options.variant) {
prompts.push({
type: 'rawlist',
name: 'variant',
message: chalk.magenta.bold('Choose a variant style:'),
choices: (answers) => {
if (!TEMPLATES[answers.template]) return ["Default"];
return TEMPLATES[answers.template].variants;
}
},
// Step 3: Architecture
{
const tmpl = options.template || answers.template;
if (!TEMPLATES[tmpl]) return ['Default'];
return TEMPLATES[tmpl].variants;
},
});
}

if (!options.arch) {
prompts.push({
type: 'rawlist',
name: 'architecture',
message: chalk.magenta.bold('Choose architecture:'),
choices: [
{ name: 'Next.js 14 — App Router (Recommended)', value: 'nextjs-monolith' },
{ name: 'Vite + React 18 — SPA', value: 'vite-react' },
{ name: 'Turborepo — Monorepo (Enterprise)', value: 'nextjs-turborepo' }
]
},
// Step 4: Design System
{
{ name: 'Turborepo — Monorepo (Enterprise)', value: 'nextjs-turborepo' },
],
});
}

if (!options.design) {
prompts.push({
type: 'rawlist',
name: 'design',
message: chalk.magenta.bold('Choose design system:'),
choices: DESIGNS
},
// Step 5: Navigation Config
{
choices: DESIGNS,
});
}

if (options.nav === undefined) {
prompts.push({
type: 'number',
name: 'navCount',
message: chalk.cyan.bold('How many navigation links? (3-9)'),
default: 5,
validate: (input) => input >= 3 && input <= 9 ? true : 'Please enter a number between 3 and 9'
},
// Step 6: Sidebar Config
{
default: defaults.navCount,
validate: (input) => (input >= 3 && input <= 9 ? true : 'Please enter a number between 3 and 9'),
});
}

// Sidebar prompt — only if template supports it and flag not provided
if (options.sidebar === undefined) {
prompts.push({
type: 'confirm',
name: 'includeSidebar',
message: chalk.cyan.bold('Include a sidebar layout?'),
default: false,
when: (answers) => {
if (!TEMPLATES[answers.template]) return false;
return TEMPLATES[answers.template].sidebarOpts;
}
},
// Step 7: Git Init Config
{
const tmpl = options.template || answers.template;
if (!TEMPLATES[tmpl]) return false;
return TEMPLATES[tmpl].sidebarOpts;
},
});
}

if (options.git !== false) {
prompts.push({
type: 'confirm',
name: 'initGit',
message: chalk.cyan.bold('Initialize a new Git repository?'),
default: true
},
// Step 8: Security Config (NEW)
{
default: true,
});
}

// Only ask security prompt in interactive mode (when not all flags provided)
const allFlagsProvided = options.template && options.variant && options.arch && options.design && options.nav !== undefined;
if (!allFlagsProvided) {
prompts.push({
type: 'confirm',
name: 'enableSecurity',
message: chalk.red.bold('Enable Enterprise Security Hardening (Zod env validation & CSP headers)?'),
default: true
}
]);
default: true,
});
}

let answers;
try {
answers = await inquirer.prompt(prompts);
} catch (error) {
if (error.name === 'ExitPromptError' || error.message?.includes('User force closed')) {
console.log(chalk.yellow('\nScaffold cancelled. Goodbye!'));
Expand All @@ -142,10 +273,57 @@ async function runOpusifyWizard() {
throw error;
}

// Merge flags with interactive answers
const config = {
projectName: projectName || answers.projectName,
template: options.template || answers.template,
variant: options.variant || answers.variant,
architecture: options.arch || answers.architecture,
design: options.design || answers.design,
navCount: options.nav || answers.navCount,
includeSidebar: options.sidebar !== undefined ? options.sidebar : (answers.includeSidebar || false),
initGit: options.git === false ? false : (answers.initGit !== undefined ? answers.initGit : true),
enableSecurity: answers.enableSecurity !== undefined ? answers.enableSecurity : true,
noInstall: options.install === false,
};

console.log('\n' + chalk.green('✔ Configuration collected successfully!'));

// Hand off the config to the generation engine!
await generateProject(config);
}

runOpusifyWizard();
// CLI setup
const program = new Command();

program
.name('opusify')
.description('The Full-Stack Scaffold Engine — Generate production-ready apps with one command.')
.version(pkg.version);

program
.command('create')
.description('Scaffold a new project')
.argument('[project-name]', 'Name of the project to create')
.option('-t, --template <template>', `Project template (${VALID_TEMPLATES.join(', ')})`)
.option('-a, --arch <architecture>', 'Architecture (nextjs, vite, turborepo)')
.option('-d, --design <design>', `Design system (e.g., "Dark Terminal", "Glassmorphism")`)
.option('-v, --variant <variant>', 'Variant style (e.g., "Developer Folio", "Fashion Store")')
.option('-n, --nav <count>', 'Number of navigation links (3-9)')
.option('--sidebar', 'Include a sidebar layout')
.option('--no-git', 'Skip Git initialization')
.option('--no-install', 'Skip npm install')
.option('-y, --yes', 'Accept all defaults (non-interactive)')
.action(createAction);

// Default command — if user just runs `opusify` without subcommand
program
.argument('[project-name]', '', '')
.action((projectName) => {
if (projectName) {
// If they run `opusify my-app`, treat it as `opusify create my-app`
createAction(projectName, {});
} else {
program.help();
}
});

program.parse();
Loading
Loading