diff --git a/src/__tests__/github.test.ts b/src/__tests__/github.test.ts index f7ec023..71f52e5 100644 --- a/src/__tests__/github.test.ts +++ b/src/__tests__/github.test.ts @@ -181,7 +181,7 @@ describe('createRepo', () => { 'repo', 'create', 'my-app', - '--public', + '--private', `--source=${projectPath}`, '--remote=origin', '--push', diff --git a/src/__tests__/save.test.ts b/src/__tests__/save.test.ts index fa90aa7..7b85812 100644 --- a/src/__tests__/save.test.ts +++ b/src/__tests__/save.test.ts @@ -199,7 +199,7 @@ describe('runSave', () => { await expect(runSave(cwd)).rejects.toMatchObject({ message: 'add failed' }); expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Save or push failed'), + expect.stringContaining('Push failed'), ); }); diff --git a/src/cli.ts b/src/cli.ts index b8a7116..3fb22ed 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -2,7 +2,6 @@ import { program } from 'commander'; import { execa } from 'execa'; -import inquirer from 'inquirer'; import fs from 'fs-extra'; import path from 'path'; import { defaultTemplates } from './templates.js'; @@ -10,18 +9,7 @@ import { mergeTemplates } from './template-loader.js'; import { offerAndCreateGitHubRepo } from './github.js'; import { runSave } from './save.js'; import { runLoad } from './load.js'; - -/** Project data provided by the user */ -type ProjectData = { - /** Project name */ - name: string, - /** Project version */ - version: string, - /** Project description */ - description: string, - /** Project author */ - author: string -} +import { runCreate } from './create.js'; /** Command to create a new project */ program @@ -32,172 +20,12 @@ program .argument('[template-name]', 'The name of the template to use') .option('-t, --template-file ', 'Path to a JSON file with custom templates (same format as built-in)') .option('--ssh', 'Use SSH URL for cloning the template repository') + .option('--repo ', 'GitHub repo visibility when creating a repo: public or private (default: private)', 'private') .action(async (projectDirectory, templateName, options) => { - const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile); - - // If project directory is not provided, prompt for it - if (!projectDirectory) { - const projectDirAnswer = await inquirer.prompt([ - { - type: 'input', - name: 'projectDirectory', - message: 'Please provide the directory where you want to create the project?', - default: 'my-app', - }, - ]); - projectDirectory = projectDirAnswer.projectDirectory; - } - - // If template name is not provided, show available templates and let user select - if (!templateName) { - console.log('\n๐Ÿ“‹ Available templates:\n'); - templatesToUse.forEach(t => { - console.log(` ${t.name.padEnd(12)} - ${t.description}`); - }); - console.log(''); - - const templateQuestion = [ - { - type: 'list', - name: 'templateName', - message: 'Select a template:', - choices: templatesToUse.map(t => ({ - name: `${t.name} - ${t.description}`, - value: t.name - })) - } - ]; - - const templateAnswer = await inquirer.prompt(templateQuestion); - templateName = templateAnswer.templateName; - } - - // Look up the template by name - const template = templatesToUse.find(t => t.name === templateName); - if (!template) { - console.error(`โŒ Template "${templateName}" not found.\n`); - console.log('๐Ÿ“‹ Available templates:\n'); - templatesToUse.forEach(t => { - console.log(` ${t.name.padEnd(12)} - ${t.description}`); - }); - console.log(''); - process.exit(1); - } - - // If --ssh was not passed, prompt whether to use SSH - let useSSH = options?.ssh; - if (useSSH === undefined && template.repoSSH) { - const sshAnswer = await inquirer.prompt([ - { - type: 'confirm', - name: 'useSSH', - message: 'Use SSH URL for cloning?', - default: false, - }, - ]); - useSSH = sshAnswer.useSSH; - } - - const templateRepoUrl = useSSH && template.repoSSH ? template.repoSSH : template.repo; - - // Define the full path for the new project - const projectPath = path.resolve(projectDirectory); - console.log(`Cloning template "${templateName}" from ${templateRepoUrl} into ${projectPath}...`); - try { - - // Clone the repository - const cloneArgs = ['clone']; - if (template.options && Array.isArray(template.options)) { - cloneArgs.push(...template.options); - } - cloneArgs.push(templateRepoUrl, projectPath); - await execa('git', cloneArgs, { stdio: 'inherit' }); - console.log('โœ… Template cloned successfully.'); - - // Remove the .git folder from the *new* project - await fs.remove(path.join(projectPath, '.git')); - console.log('๐Ÿงน Cleaned up template .git directory.'); - - // Ask user for customization details - const questions = [ - { - type: 'input', - name: 'name', - message: 'What is the project name?', - default: path.basename(projectPath), - }, - { - type: 'input', - name: 'version', - message: 'What version number would you like to use?', - default: '1.0.0', - }, - { - type: 'input', - name: 'description', - message: 'What is the project description?', - default: '', - }, - { - type: 'input', - name: 'author', - message: 'Who is the author of the project?', - default: '', - }, - ]; - - const answers: ProjectData = await inquirer.prompt(questions); - - // Update the package.json in the new project - const pkgJsonPath = path.join(projectPath, 'package.json'); - - if (await fs.pathExists(pkgJsonPath)) { - const pkgJson = await fs.readJson(pkgJsonPath); - - // Overwrite fields with user's answers - pkgJson.name = answers.name; - pkgJson.version = answers.version; - pkgJson.description = answers.description; - pkgJson.author = answers.author; - - // Write the updated package.json back - await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); - console.log('๐Ÿ“ Customized package.json.'); - } else { - console.log('โ„น๏ธ No package.json found in template, skipping customization.'); - } - - const packageManager = template.packageManager || "npm"; - // Install dependencies - console.log('๐Ÿ“ฆ Installing dependencies... (This may take a moment)'); - await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' }); - console.log('โœ… Dependencies installed.'); - - // Optional: Create GitHub repository - await offerAndCreateGitHubRepo(projectPath); - - // Let the user know the project was created successfully - console.log('\nโœจ Project created successfully! โœจ\n'); - console.log(`To get started:`); - console.log(` cd ${projectDirectory}`); - console.log(' Happy coding! ๐Ÿš€'); - - } catch (error) { - console.error('โŒ An error occurred:'); - if (error instanceof Error) { - console.error(error.message); - } else if (error && typeof error === 'object' && 'stderr' in error) { - console.error((error as { stderr?: string }).stderr || String(error)); - } else { - console.error(String(error)); - } - - // Clean up the created directory if an error occurred - if (await fs.pathExists(projectPath)) { - await fs.remove(projectPath); - console.log('๐Ÿงน Cleaned up failed project directory.'); - } + await runCreate(projectDirectory, templateName, options); + } catch { + process.exit(1); } }); @@ -304,4 +132,48 @@ program } }); +/** Command to install, build, and start the project */ +program + .command('start') + .description('Install dependencies, build, and start the project') + .argument('[path]', 'Path to the project directory (defaults to current directory)') + .option('-p, --package-manager ', 'Package manager to use (npm, yarn, or pnpm)', 'npm') + .action(async (projectPath, options) => { + const cwd = projectPath ? path.resolve(projectPath) : process.cwd(); + const packageManager = options.packageManager ?? 'npm'; + + const validManagers = ['npm', 'yarn', 'pnpm']; + if (!validManagers.includes(packageManager)) { + console.error(`โŒ Invalid package manager "${packageManager}". Use one of: ${validManagers.join(', ')}`); + process.exit(1); + } + + const installArgs = packageManager === 'yarn' ? [] : ['install']; + const buildArgs = packageManager === 'yarn' ? ['build'] : ['run', 'build']; + const startArgs = ['start']; + + try { + console.log(`๐Ÿ“ฆ Installing dependencies (${packageManager})...`); + await execa(packageManager, installArgs, { cwd, stdio: 'inherit' }); + console.log('โœ… Dependencies installed.'); + + console.log('๐Ÿ”จ Building...'); + await execa(packageManager, buildArgs, { cwd, stdio: 'inherit' }); + console.log('โœ… Build complete.'); + + console.log('๐Ÿš€ Starting...'); + await execa(packageManager, startArgs, { cwd, stdio: 'inherit' }); + } catch (error) { + console.error('โŒ An error occurred:'); + if (error instanceof Error) { + console.error(error.message); + } else if (error && typeof error === 'object' && 'stderr' in error) { + console.error((error as { stderr?: string }).stderr || String(error)); + } else { + console.error(String(error)); + } + process.exit(1); + } + }); + program.parse(process.argv); \ No newline at end of file diff --git a/src/create.ts b/src/create.ts new file mode 100644 index 0000000..7bd4cd2 --- /dev/null +++ b/src/create.ts @@ -0,0 +1,172 @@ +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; +import inquirer from 'inquirer'; +import { defaultTemplates } from './templates.js'; +import { mergeTemplates } from './template-loader.js'; +import { offerAndCreateGitHubRepo } from './github.js'; + +/** Project data provided by the user */ +type ProjectData = { + name: string; + version: string; + description: string; + author: string; +}; + +export type CreateOptions = { + templateFile?: string; + ssh?: boolean; + repo?: string; +}; + +/** + * Runs the create flow: prompt for directory/template if needed, clone template, + * customize package.json, install deps, optionally create GitHub repo. + */ +export async function runCreate( + projectDirectory: string | undefined, + templateName: string | undefined, + options?: CreateOptions +): Promise { + const templatesToUse = mergeTemplates(defaultTemplates, options?.templateFile); + + if (!projectDirectory) { + const projectDirAnswer = await inquirer.prompt([ + { + type: 'input', + name: 'projectDirectory', + message: 'Please provide the directory where you want to create the project?', + default: 'my-app', + }, + ]); + projectDirectory = projectDirAnswer.projectDirectory; + } + + if (!templateName) { + console.log('\n๐Ÿ“‹ Available templates:\n'); + templatesToUse.forEach(t => { + console.log(` ${t.name.padEnd(12)} - ${t.description}`); + }); + console.log(''); + + const templateQuestion = [ + { + type: 'list', + name: 'templateName', + message: 'Select a template:', + choices: templatesToUse.map(t => ({ + name: `${t.name} - ${t.description}`, + value: t.name, + })), + }, + ]; + + const templateAnswer = await inquirer.prompt(templateQuestion); + templateName = templateAnswer.templateName; + } + + const template = templatesToUse.find(t => t.name === templateName); + if (!template) { + console.error(`โŒ Template "${templateName}" not found.\n`); + console.log('๐Ÿ“‹ Available templates:\n'); + templatesToUse.forEach(t => { + console.log(` ${t.name.padEnd(12)} - ${t.description}`); + }); + console.log(''); + process.exit(1); + } + + const useSSH = options?.ssh ?? false; + const templateRepoUrl = useSSH && template.repoSSH ? template.repoSSH : template.repo; + + const dir = projectDirectory as string; + const projectPath = path.resolve(dir); + console.log(`Cloning template "${templateName}" from ${templateRepoUrl} into ${projectPath}...`); + + try { + const cloneArgs = ['clone']; + if (template.options && Array.isArray(template.options)) { + cloneArgs.push(...template.options); + } + cloneArgs.push(templateRepoUrl, projectPath); + await execa('git', cloneArgs, { stdio: 'inherit' }); + console.log('โœ… Template cloned successfully.'); + + await fs.remove(path.join(projectPath, '.git')); + console.log('๐Ÿงน Cleaned up template .git directory.'); + + const questions = [ + { + type: 'input', + name: 'name', + message: 'What is the project name?', + default: path.basename(projectPath), + }, + { + type: 'input', + name: 'version', + message: 'What version number would you like to use?', + default: '1.0.0', + }, + { + type: 'input', + name: 'description', + message: 'What is the project description?', + default: '', + }, + { + type: 'input', + name: 'author', + message: 'Who is the author of the project?', + default: '', + }, + ]; + + const answers: ProjectData = await inquirer.prompt(questions); + + const pkgJsonPath = path.join(projectPath, 'package.json'); + + if (await fs.pathExists(pkgJsonPath)) { + const pkgJson = await fs.readJson(pkgJsonPath); + + pkgJson.name = answers.name; + pkgJson.version = answers.version; + pkgJson.description = answers.description; + pkgJson.author = answers.author; + + await fs.writeJson(pkgJsonPath, pkgJson, { spaces: 2 }); + console.log('๐Ÿ“ Customized package.json.'); + } else { + console.log('โ„น๏ธ No package.json found in template, skipping customization.'); + } + + const packageManager = template.packageManager || 'npm'; + console.log('๐Ÿ“ฆ Installing dependencies... (This may take a moment)'); + await execa(packageManager, ['install'], { cwd: projectPath, stdio: 'inherit' }); + console.log('โœ… Dependencies installed.'); + + const visibility = options?.repo === 'public' ? 'public' : 'private'; + await offerAndCreateGitHubRepo(projectPath, { visibility }); + + console.log('\nโœจ Project created successfully! โœจ\n'); + console.log(`To get started:`); + console.log(` cd ${dir}`); + console.log(' Happy coding! ๐Ÿš€'); + } catch (error) { + console.error('โŒ An error occurred:'); + if (error instanceof Error) { + console.error(error.message); + } else if (error && typeof error === 'object' && 'stderr' in error) { + console.error((error as { stderr?: string }).stderr || String(error)); + } else { + console.error(String(error)); + } + + if (await fs.pathExists(projectPath)) { + await fs.remove(projectPath); + console.log('๐Ÿงน Cleaned up failed project directory.'); + } + throw error; + } +} diff --git a/src/github.ts b/src/github.ts index 4840d74..f765c3e 100644 --- a/src/github.ts +++ b/src/github.ts @@ -76,6 +76,7 @@ export async function createRepo(options: { projectPath: string; username: string; description?: string; + visibility?: 'public' | 'private'; }): Promise { const gitDir = path.join(options.projectPath, '.git'); if (!(await fs.pathExists(gitDir))) { @@ -83,11 +84,12 @@ export async function createRepo(options: { } await ensureInitialCommit(options.projectPath); + const visibility = options.visibility === 'public' ? '--public' : '--private'; const args = [ 'repo', 'create', options.repoName, - '--public', + visibility, `--source=${options.projectPath}`, '--remote=origin', '--push', @@ -102,8 +104,13 @@ export async function createRepo(options: { /** * Interactive flow: prompt to create a GitHub repo under the current user, then create it and set origin. * Returns true if a repo was created (or already had origin), false if skipped or failed. + * @param visibility - 'public' or 'private' (default: 'private') */ -export async function offerAndCreateGitHubRepo(projectPath: string): Promise { +export async function offerAndCreateGitHubRepo( + projectPath: string, + options?: { visibility?: 'public' | 'private' } +): Promise { + const visibility = options?.visibility ?? 'private'; const pkgJsonPath = path.join(projectPath, 'package.json'); if (!(await fs.pathExists(pkgJsonPath))) { console.log('\nโ„น๏ธ No package.json found; skipping GitHub repository creation.\n'); @@ -153,7 +160,7 @@ export async function offerAndCreateGitHubRepo(projectPath: string): Promise { await execa('git', ['pull'], { cwd, stdio: 'inherit' }); console.log('\nโœ… Latest updates loaded successfully.\n'); } catch (err) { - if (err && typeof err === 'object' && 'exitCode' in err) { - const code = (err as { exitCode?: number }).exitCode; - if (code === 128) { - console.error( - '\nโŒ Pull failed. You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".\n', - ); - } else { - console.error('\nโŒ Pull failed. See the output above for details.\n'); - } - } else { - console.error('\nโŒ An error occurred:'); - if (err instanceof Error) console.error(` ${err.message}\n`); - else console.error(` ${String(err)}\n`); - } - throw err; + handleGitError(err, 'Pull', 'You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".'); } } diff --git a/src/save.ts b/src/save.ts index 282440f..7006562 100644 --- a/src/save.ts +++ b/src/save.ts @@ -3,6 +3,7 @@ import fs from 'fs-extra'; import { execa } from 'execa'; import inquirer from 'inquirer'; import { offerAndCreateGitHubRepo } from './github.js'; +import { handleGitError } from './util.js'; /** * Runs the save flow: verify repo, check for changes, prompt to commit, then add/commit/push. @@ -55,7 +56,7 @@ export async function runSave(cwd: string): Promise { const commitMessage = (message as string).trim(); if (!commitMessage) { - console.log('\n๐Ÿ“ญ No message provided; nothing has been saved.\n'); + console.log('\nNo message provided.\n'); return; } @@ -84,20 +85,6 @@ export async function runSave(cwd: string): Promise { await execa('git', ['push'], { cwd, stdio: 'inherit' }); console.log('\nโœ… Changes saved and pushed to GitHub successfully.\n'); } catch (err) { - if (err && typeof err === 'object' && 'exitCode' in err) { - const code = (err as { exitCode?: number }).exitCode; - if (code === 128) { - console.error( - '\nโŒ Push failed. You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".\n', - ); - } else { - console.error('\nโŒ Save or push failed. See the output above for details.\n'); - } - } else if (!(err instanceof Error && err.message === 'No remote origin')) { - console.error('\nโŒ An error occurred:'); - if (err instanceof Error) console.error(` ${err.message}\n`); - else console.error(` ${String(err)}\n`); - } - throw err; + handleGitError(err, 'Push', 'You may need to set a remote (e.g. "git remote add origin ") or run "gh auth login".'); } } diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..1d65472 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,16 @@ +export function handleGitError(err: unknown, operation: string, message: string): void { + const withExitCode = err && err instanceof Error && 'exitCode' in err; + if (withExitCode) { + const code = err.exitCode; + if (code === 128) { + console.error(`\nโŒ ${operation} failed. ${message}`); + } else { + console.error(`\nโŒ ${operation} failed. See the output above for details.\n`); + } + } else { + console.error('\nโŒ An error occurred:'); + if (err instanceof Error) console.error(` ${err.message}\n`); + else console.error(` ${String(err)}\n`); + } + throw err; +} \ No newline at end of file