diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..eb5179b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: Publish to npm + +on: + push: + tags: + - 'v*' + branches: + - main + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + registry-url: 'https://registry.npmjs.org' + + - run: npm ci + + - run: npm run lint + + - name: Publish to npm + run: npm publish --provenance --access public + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/templates.yml b/.github/workflows/templates.yml new file mode 100644 index 0000000..2433bc6 --- /dev/null +++ b/.github/workflows/templates.yml @@ -0,0 +1,34 @@ +name: Template Tests + +on: + pull_request: + branches: [main, dev] + paths: + - 'templates/**' + - 'src/generate.js' + - 'src/dependencies.js' + - 'scripts/test-templates.js' + +jobs: + test-templates: + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + template: [portfolio, ecommerce, school, saas, blog] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install CLI dependencies + run: npm ci + + - name: Test ${{ matrix.template }} template + run: node scripts/test-single-template.js ${{ matrix.template }} diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..f3839e2 --- /dev/null +++ b/.npmignore @@ -0,0 +1,25 @@ +# Templates (fetched from GitHub at runtime) +templates/ + +# Development files +scripts/ +.github/ +.husky/ +ISSUES.md +CONTRIBUTING.md + +# Test outputs +my-opusify-app*/ +test-*/ + +# Config files not needed in package +.eslintrc.json +.prettierrc +.gitignore + +# Dependencies +node_modules/ + +# OS files +.DS_Store +Thumbs.db diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..12ee7c4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,56 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity and +orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information without explicit permission +- Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders at **conduct@ebytesoftlab.dev**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html](https://www.contributor-covenant.org/version/2/1/code_of_conduct.html). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..fa09400 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Ebyte Soft Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/index.js b/index.js index 667bcfc..b98267b 100755 --- a/index.js +++ b/index.js @@ -1,66 +1,184 @@ #!/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'; +import { addAction } from './src/commands/add.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(); + + // 1. Validate project name if provided as an argument + if (projectName && !/^[a-z0-9-]+$/.test(projectName)) { + console.log(chalk.red(`\n✖ Invalid project name: "${projectName}"`)); + console.log(chalk.gray(' Suggested fix: Please use only lowercase letters, numbers, and hyphens (e.g., my-awesome-app).')); + process.exit(1); + } + + // 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, + verbose: options.verbose || false, + repo: options.repo || 'Ebyte-Lab/opusify-templates', + token: options.token || process.env.OPUSIFY_GITHUB_TOKEN || process.env.GITHUB_TOKEN, + }; + + 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:'), @@ -69,71 +187,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!')); @@ -142,10 +284,149 @@ 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, + verbose: options.verbose || false, + repo: options.repo || 'Ebyte-Lab/opusify-templates', + token: options.token || process.env.OPUSIFY_GITHUB_TOKEN || process.env.GITHUB_TOKEN, + }; + + // 2. Warn if selecting Vite SPA for E-Commerce (SEO concern) + if (config.template === 'ecommerce' && config.architecture.includes('vite')) { + console.log(chalk.yellow('\n⚠️ WARNING: You selected Vite SPA for an E-Commerce template.')); + console.log(chalk.gray(' Vite generates a purely Client-Side application (No SSR).')); + console.log(chalk.gray(' This severely impacts SEO, which is crucial for E-Commerce stores.')); + console.log(chalk.gray(' Suggested fix: We highly recommend using Next.js App Router instead.')); + + // If not in --yes mode, ask for confirmation + if (!options.yes) { + const { proceed } = await inquirer.prompt([{ + type: 'confirm', + name: 'proceed', + message: chalk.yellow('Do you still want to proceed with Vite?'), + default: false + }]); + if (!proceed) { + console.log(chalk.yellow('\nScaffold cancelled. Please run the command again.')); + process.exit(0); + } + } + } + console.log('\n' + chalk.green('✔ Configuration collected successfully!')); - - // Hand off the config to the generation engine! await generateProject(config); } -runOpusifyWizard(); \ No newline at end of file +// Architecture availability map +const ARCHITECTURES = { + 'nextjs-monolith': { label: 'Next.js 14 — App Router', available: ['portfolio', 'ecommerce', 'school', 'saas', 'blog'] }, + 'vite-react': { label: 'Vite + React 18 — SPA', available: ['portfolio', 'ecommerce', 'saas'] }, + 'nextjs-turborepo': { label: 'Turborepo — Monorepo', available: [] }, +}; + +function listAction(options) { + console.log(''); + console.log(chalk.blue.bold(' Opusify — Available Templates')); + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────')); + console.log(''); + + if (options.template && !TEMPLATES[options.template]) { + console.log(chalk.red(` ✖ Unknown template: "${options.template}"`)); + console.log(chalk.gray(` Valid options: ${VALID_TEMPLATES.join(', ')}`)); + process.exit(1); + } + + const templatesToShow = options.template + ? { [options.template]: TEMPLATES[options.template] } + : TEMPLATES; + + for (const [key, tmpl] of Object.entries(templatesToShow)) { + console.log(chalk.white.bold(` ${tmpl.label}`) + chalk.gray(` (${key})`)); + console.log(''); + + // Variants + console.log(chalk.cyan(' Variants:')); + for (const variant of tmpl.variants) { + console.log(chalk.white(` • ${variant}`)); + } + console.log(''); + + // Architectures + console.log(chalk.cyan(' Architectures:')); + for (const [archKey, arch] of Object.entries(ARCHITECTURES)) { + const isAvailable = arch.available.includes(key); + if (isAvailable) { + console.log(chalk.green(` ✔ ${arch.label}`) + chalk.gray(` (${archKey})`)); + } else { + console.log(chalk.gray(` ○ ${arch.label} — coming soon`)); + } + } + console.log(''); + + // Sidebar support + console.log(chalk.cyan(' Sidebar:') + (tmpl.sidebarOpts ? chalk.green(' supported') : chalk.gray(' not applicable'))); + console.log(''); + console.log(chalk.gray(' ─────────────────────────────────────────────────────────────')); + console.log(''); + } + + // Design systems (show once at the bottom) + if (!options.template) { + console.log(chalk.blue.bold(' Design Systems')); + console.log(''); + for (const design of DESIGNS) { + console.log(chalk.white(` • ${design}`)); + } + console.log(''); + } +} + +// 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