commit 7a6b6b8ae7eb7159ab1a911551c35a709114d5f4 Author: Harvey Zuccon Date: Tue Aug 5 22:41:36 2025 +1000 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db63d27 --- /dev/null +++ b/.gitignore @@ -0,0 +1,80 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Build output +dist/ +build/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..0f95c60 --- /dev/null +++ b/README.md @@ -0,0 +1,163 @@ +# Gitea Creator + +A command line tool to create repositories on Gitea servers with automatic git setup. + +## Features + +- Create repositories on Gitea servers via API +- Interactive prompts for git initialization and setup +- Automatic remote configuration +- Optional automatic pushing to the new repository +- Support for public/private repository creation +- Comprehensive .gitignore creation for Node.js projects + +## Installation + +### Option 1: Local Development +1. Clone this repository +2. Install dependencies: `npm install` +3. Build the project: `npm run build` +4. The CLI will be available at `./dist/index.js` + +### Option 2: Global Installation (Development) +1. Clone this repository +2. Install dependencies: `npm install` +3. Build the project: `npm run build` +4. Link globally: `npm link` +5. Use `gitea-creator` command anywhere + +### Option 3: Global Installation (from npm) +```bash +# If published to npm +npm install -g gitea-creator + +# Use anywhere +gitea-creator my-repo --public +``` + +### Option 4: Direct Usage +```bash +# Run directly without global installation +node dist/index.js [options] +``` + +## Environment Setup + +### For Local Development +Create a `.env` file (copy from `env.example`): + +```bash +# Copy the example file +cp env.example .env + +# Edit the .env file with your values +# GITEA_TOKEN=your_gitea_access_token +# GITEA_API_URL=https://your-gitea-server.com/api/v1 +``` + +### For Global Installation +When installed globally, the tool supports multiple configuration methods: + +#### Option 1: System Environment Variables (Recommended) +```bash +# Add to your shell profile (~/.bashrc, ~/.zshrc, etc.) +export GITEA_TOKEN=your_gitea_access_token +export GITEA_API_URL=https://your-gitea-server.com/api/v1 + +# Reload your shell +source ~/.zshrc # or ~/.bashrc +``` + +#### Option 2: Global Configuration File +```bash +# Create global config directory +mkdir -p ~/.gitea-creator + +# Create .env file +echo "GITEA_TOKEN=your_token_here" > ~/.gitea-creator/.env +echo "GITEA_API_URL=https://your-gitea-server.com/api/v1" >> ~/.gitea-creator/.env +``` + +#### Option 3: View Configuration Instructions +```bash +# Get setup instructions anytime +gitea-creator --config +# or short form +gitea-creator -c +``` + +### Getting a Gitea Access Token +1. Log into your Gitea server +2. Go to Settings → Applications +3. Click "Manage Access Tokens" +4. Generate a new token with repository permissions + +## Usage + +```bash +gitea-creator [options] +``` + +### Options + +- `-p, --public`: Create a public repository (non-private) +- `-d, --description `: Add a description to the repository +- `-h, --help`: Display help information + +### Examples + +```bash +# Create a private repository +gitea-creator my-new-repo + +# Create a public repository with description +gitea-creator my-public-repo --public --description "My awesome project" + +# Short form +gitea-creator my-repo -p -d "Short description" + +# Get configuration help +gitea-creator --config +``` + +## Workflow + +When you run the tool, it will: + +1. **Create Repository**: Create the repository on your Gitea server with the specified options +2. **Display URL**: Show the remote clone URL for the new repository +3. **Git Initialization**: If no git repository exists, ask if you want to initialize one + - Creates `.gitignore` with Node.js patterns if initializing +4. **Remote Setup**: Ask if you want to set the new repository as the remote origin (defaults to yes) +5. **Pushing**: Ask if you want to push the current repository (defaults to yes) + - Automatically handles initial commits if there are uncommitted changes + - Renames `master` branch to `main` if needed + +### Interactive Prompts +All prompts default to "yes" - you can just press Enter to accept the defaults: +- Initialize git repository? (if none exists) +- Set remote origin? +- Push to remote? +- Commit changes first? (if there are uncommitted changes) + +## Development + +```bash +# Install dependencies +npm install + +# Build the project +npm run build + +# Run in development mode +npm run dev + +# Test the CLI +node dist/index.js my-test-repo --public +``` + +## Requirements + +- Node.js 18+ +- Git (for git operations) +- Valid Gitea server access token \ No newline at end of file diff --git a/env.example b/env.example new file mode 100644 index 0000000..687704f --- /dev/null +++ b/env.example @@ -0,0 +1,13 @@ +# Gitea Creator Configuration +# Copy this file to .env and fill in your values + +# Your Gitea personal access token +# You can create one in your Gitea settings under "Applications" -> "Manage Access Tokens" +GITEA_TOKEN=26e3b54850d1b433117c1494853ffcfab501c53b + +# Your Gitea server API URL (include /api/v1 at the end) +# Examples: +# GITEA_API_URL=https://git.harvmaster.com/api/v1 +# GITEA_API_URL=https://gitea.mycompany.com/api/v1 +# GITEA_API_URL=https://codeberg.org/api/v1 +GITEA_API_URL=https://git.harvmaster.com/api/v1 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..9833e4e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,104 @@ +{ + "name": "gitea-creator", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "gitea-creator", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "commander": "^12.1.0", + "prompts": "^2.4.2" + }, + "bin": { + "gitea-creator": "dist/index.js" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "@types/prompts": "^2.4.9", + "typescript": "^5.9.2" + } + }, + "node_modules/@types/node": { + "version": "24.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.2.0.tgz", + "integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.10.0" + } + }, + "node_modules/@types/prompts": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/@types/prompts/-/prompts-2.4.9.tgz", + "integrity": "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "kleur": "^3.0.3" + } + }, + "node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/prompts": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.10.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", + "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..c32515d --- /dev/null +++ b/package.json @@ -0,0 +1,33 @@ +{ + "name": "gitea-creator", + "version": "1.0.0", + "description": "CLI tool to create repositories on Gitea servers", + "main": "dist/index.js", + "type": "module", + "bin": { + "gitea-creator": "./dist/index.js" + }, + "scripts": { + "build": "tsc", + "dev": "tsc && node dist/index.js", + "prepublishOnly": "npm run build", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "files": [ + "dist/**/*", + "README.md", + "env.example" + ], + "keywords": ["gitea", "git", "cli", "repository"], + "author": "harvmaster", + "license": "ISC", + "dependencies": { + "commander": "^12.1.0", + "prompts": "^2.4.2" + }, + "devDependencies": { + "@types/node": "^24.2.0", + "@types/prompts": "^2.4.9", + "typescript": "^5.9.2" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..3602ee6 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,80 @@ +import { Command } from 'commander'; +import { GlobalConfig } from './global-config.js'; + +/** + * Command line interface options for creating a Gitea repository + */ +export interface CliOptions { + /** Repository name (required) */ + name: string; + /** Repository description */ + description?: string; + /** Whether the repository should be public (non-private) */ + public?: boolean; + /** Show configuration instructions */ + config?: boolean; +} + +/** + * CLI service for parsing command line arguments + */ +export class CliService { + private readonly program: Command; + + constructor() { + this.program = new Command(); + this.setupCommand(); + } + + /** + * Set up the command line interface with all options and arguments + */ + private setupCommand(): void { + this.program + .name('gitea-creator') + .description('CLI tool to create repositories on Gitea servers') + .version('1.0.0') + .argument('', 'Name of the repository to create') + .option('-p, --public', 'Create a public repository (non-private)', false) + .option('-d, --description ', 'Repository description') + .option('-c, --config', 'Show configuration setup instructions') + .helpOption('-h, --help', 'Display help for command'); + } + + /** + * Parse command line arguments and return options + * @param argv - Command line arguments (defaults to process.argv) + * @returns Parsed CLI options + */ + parse(argv?: string[]): CliOptions { + const parsed = this.program.parse(argv); + const options = parsed.opts(); + const [name] = parsed.args; + + // Handle config option + if (options.config) { + console.log(GlobalConfig.getSetupInstructions()); + process.exit(0); + } + + if (!name) { + console.error('❌ Error: Repository name is required'); + this.program.help(); + process.exit(1); + } + + return { + name, + description: options.description, + public: options.public, + config: options.config, + }; + } + + /** + * Display help information + */ + help(): void { + this.program.help(); + } +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..06cc521 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,35 @@ +export type ConfigRaw = { + GITEA_TOKEN: string; + GITEA_API_URL: string; +} + +import { GlobalConfig } from './global-config.js'; + +export class Config { + static from(config: Record) { + const token = config.GITEA_TOKEN; + if (!token) { + const instructions = GlobalConfig.getSetupInstructions(); + throw new Error(`GITEA_TOKEN is not set.\n${instructions}`); + } + + const apiUrl = config.GITEA_API_URL; + if (!apiUrl) { + const instructions = GlobalConfig.getSetupInstructions(); + throw new Error(`GITEA_API_URL is not set.\n${instructions}`); + } + + return new Config({ + GITEA_TOKEN: token, + GITEA_API_URL: apiUrl, + }); + } + + readonly token: string; + readonly apiUrl: string; + + constructor(config: ConfigRaw) { + this.token = config.GITEA_TOKEN; + this.apiUrl = config.GITEA_API_URL; + } +} \ No newline at end of file diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..28d0b6b --- /dev/null +++ b/src/env.ts @@ -0,0 +1,28 @@ +import { GlobalConfig } from './global-config.js'; + +/** + * Environment variables configuration + * Supports local .env, global config, and system environment variables + */ +function getConfig(): Record { + // For local development, try to read local .env first + try { + // This will only work if we're in a project directory with .env + const localEnv = { + GITEA_TOKEN: process.env.GITEA_TOKEN || '', + GITEA_API_URL: process.env.GITEA_API_URL || '', + }; + + // If we have local env vars, use them + if (localEnv.GITEA_TOKEN && localEnv.GITEA_API_URL) { + return localEnv; + } + } catch { + // Ignore local env errors + } + + // Fall back to global configuration + return GlobalConfig.getConfig(); +} + +export const env = getConfig(); \ No newline at end of file diff --git a/src/git.ts b/src/git.ts new file mode 100644 index 0000000..993470f --- /dev/null +++ b/src/git.ts @@ -0,0 +1,215 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import { existsSync } from 'fs'; +import { writeFile } from 'fs/promises'; +import path from 'path'; + +const execAsync = promisify(exec); + +/** + * Git service for handling git operations + */ +export class GitService { + /** + * Check if the current directory is a git repository + * @returns true if git is initialized, false otherwise + */ + async isGitInitialized(): Promise { + try { + await execAsync('git status'); + return true; + } catch { + return false; + } + } + + /** + * Initialize a new git repository + */ + async initializeGit(): Promise { + try { + await execAsync('git init'); + console.log('✅ Git repository initialized'); + } catch (error) { + throw new Error(`Failed to initialize git: ${error}`); + } + } + + /** + * Create a .gitignore file with common Node.js patterns + */ + async createGitignore(): Promise { + const gitignoreContent = `# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ + +# nyc test coverage +.nyc_output + +# Dependency directories +node_modules/ +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test +.env.production +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# next.js build output +.next + +# nuxt.js build output +.nuxt + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# Build output +dist/ +build/ +`; + + try { + await writeFile('.gitignore', gitignoreContent); + console.log('✅ .gitignore file created'); + } catch (error) { + throw new Error(`Failed to create .gitignore: ${error}`); + } + } + + /** + * Add a remote origin to the git repository + * @param remoteUrl - The remote URL to add + */ + async addRemote(remoteUrl: string): Promise { + try { + // Check if origin already exists + try { + await execAsync('git remote get-url origin'); + // If we get here, origin exists, so update it + await execAsync(`git remote set-url origin ${remoteUrl}`); + console.log('✅ Updated remote origin'); + } catch { + // Origin doesn't exist, add it + await execAsync(`git remote add origin ${remoteUrl}`); + console.log('✅ Added remote origin'); + } + } catch (error) { + throw new Error(`Failed to add remote: ${error}`); + } + } + + /** + * Check if there are any commits in the repository + * @returns true if there are commits, false otherwise + */ + async hasCommits(): Promise { + try { + await execAsync('git log --oneline -1'); + return true; + } catch { + return false; + } + } + + /** + * Check if there are any staged or unstaged changes + * @returns true if there are changes, false otherwise + */ + async hasChanges(): Promise { + try { + const { stdout } = await execAsync('git status --porcelain'); + return stdout.trim().length > 0; + } catch { + return false; + } + } + + /** + * Add all files and commit them + * @param message - Commit message + */ + async addAndCommit(message: string = 'Initial commit'): Promise { + try { + await execAsync('git add .'); + await execAsync(`git commit -m "${message}"`); + console.log('✅ Files committed'); + } catch (error) { + throw new Error(`Failed to commit: ${error}`); + } + } + + /** + * Push to the remote repository + * @param branch - Branch to push (defaults to current branch) + */ + async push(branch: string = 'main'): Promise { + try { + // First, check if we're on the main/master branch and rename if needed + const { stdout: currentBranch } = await execAsync('git branch --show-current'); + const current = currentBranch.trim(); + + if (current === 'master' && branch === 'main') { + await execAsync('git branch -m master main'); + console.log('✅ Renamed branch from master to main'); + } + + await execAsync(`git push -u origin ${branch}`); + console.log('✅ Pushed to remote repository'); + } catch (error) { + throw new Error(`Failed to push: ${error}`); + } + } +} \ No newline at end of file diff --git a/src/global-config.ts b/src/global-config.ts new file mode 100644 index 0000000..4e9b6a8 --- /dev/null +++ b/src/global-config.ts @@ -0,0 +1,127 @@ +import { readFileSync, existsSync } from 'fs'; +import { homedir } from 'os'; +import path from 'path'; + +/** + * Global configuration manager for the CLI tool + * Supports both environment variables and global config files + */ +export class GlobalConfig { + private static readonly CONFIG_DIR = path.join(homedir(), '.gitea-creator'); + private static readonly CONFIG_FILE = path.join(this.CONFIG_DIR, 'config'); + private static readonly ENV_FILE = path.join(this.CONFIG_DIR, '.env'); + + /** + * Get configuration from multiple sources in priority order: + * 1. Environment variables + * 2. Global .env file (~/.gitea-creator/.env) + * 3. Global config file (~/.gitea-creator/config) + * @returns Configuration object + */ + static getConfig(): Record { + const config: Record = {}; + + // Try global config file first (lowest priority) + try { + if (existsSync(this.CONFIG_FILE)) { + const configContent = readFileSync(this.CONFIG_FILE, 'utf-8'); + this.parseConfigContent(configContent, config); + } + } catch (error) { + // Ignore config file errors + } + + // Try global .env file (medium priority) + try { + if (existsSync(this.ENV_FILE)) { + const envContent = readFileSync(this.ENV_FILE, 'utf-8'); + this.parseEnvContent(envContent, config); + } + } catch (error) { + // Ignore .env file errors + } + + // Environment variables (highest priority) + if (process.env.GITEA_TOKEN) { + config.GITEA_TOKEN = process.env.GITEA_TOKEN; + } + if (process.env.GITEA_API_URL) { + config.GITEA_API_URL = process.env.GITEA_API_URL; + } + + return config; + } + + /** + * Parse configuration file content + * Format: KEY=value (one per line) + */ + private static parseConfigContent(content: string, config: Record): void { + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && valueParts.length > 0) { + config[key.trim()] = valueParts.join('=').trim(); + } + } + } + } + + /** + * Parse .env file content + * Format: KEY=value (supports quotes) + */ + private static parseEnvContent(content: string, config: Record): void { + const lines = content.split('\n'); + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed && !trimmed.startsWith('#')) { + const [key, ...valueParts] = trimmed.split('='); + if (key && valueParts.length > 0) { + let value = valueParts.join('=').trim(); + // Remove quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + config[key.trim()] = value; + } + } + } + } + + /** + * Get the path where global config should be stored + */ + static getConfigPath(): string { + return this.CONFIG_DIR; + } + + /** + * Get instructions for setting up global configuration + */ + static getSetupInstructions(): string { + return ` +Global Configuration Setup: + +Option 1: Environment Variables (Recommended) + Add to your shell profile (~/.bashrc, ~/.zshrc, etc.): + export GITEA_TOKEN=your_token_here + export GITEA_API_URL=https://your-gitea-server.com/api/v1 + +Option 2: Global Config File + Create: ${this.CONFIG_DIR}/.env + Content: + GITEA_TOKEN=your_token_here + GITEA_API_URL=https://your-gitea-server.com/api/v1 + +Option 3: Simple Config File + Create: ${this.CONFIG_DIR}/config + Content: + GITEA_TOKEN=your_token_here + GITEA_API_URL=https://your-gitea-server.com/api/v1 +`; + } +} \ No newline at end of file diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..4ad5531 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +import { Repo, type InitializationOptions, type GiteaRepository } from "./repo.js"; +import { Config } from "./config.js"; +import { env } from "./env.js"; +import { CliService } from "./cli.js"; +import { GitService } from "./git.js"; +import { PromptService } from "./prompts.js"; + +/** + * Main application class that orchestrates the CLI workflow + */ +export class App { + private config?: Config; + private readonly gitService: GitService; + private readonly promptService: PromptService; + + constructor() { + this.gitService = new GitService(); + this.promptService = new PromptService(); + } + + /** + * Initialize configuration (lazy loading) + */ + private initConfig(): void { + if (!this.config) { + this.config = Config.from(env); + } + } + + /** + * Create a repository on Gitea with the given options + * @param options - Repository creation options + * @returns Repository data from Gitea API + */ + async createRepo(options: InitializationOptions): Promise { + this.initConfig(); + const repo = await Repo.create(this.config!, options); + return repo; + } + + /** + * Main application workflow + */ + async run(): Promise { + try { + // Parse command line arguments + const cliService = new CliService(); + const options = cliService.parse(); + + console.log(`🚀 Creating repository: ${options.name}`); + if (options.description) { + console.log(`📝 Description: ${options.description}`); + } + console.log(`🔒 Visibility: ${options.public ? 'Public' : 'Private'}`); + console.log(''); + + // Create repository on Gitea + const repoData = await this.createRepo({ + name: options.name, + description: options.description || '', + private: !options.public, // CLI uses --public flag, but API expects private boolean + }); + + console.log('✅ Repository created successfully!'); + console.log(`🌐 Remote URL: ${repoData.clone_url}`); + console.log(''); + + // Handle git operations + await this.handleGitOperations(repoData.clone_url); + + } catch (error) { + console.error('❌ Error:', error instanceof Error ? error.message : String(error)); + process.exit(1); + } + } + + /** + * Handle git-related operations (initialization, remote setup, pushing) + * @param remoteUrl - The remote repository URL + */ + private async handleGitOperations(remoteUrl: string): Promise { + const isGitRepo = await this.gitService.isGitInitialized(); + + // If no git repo, ask if user wants to initialize one + if (!isGitRepo) { + const shouldInit = await this.promptService.askInitializeGit(); + if (!shouldInit) { + console.log('ℹ️ Skipping git setup'); + return; + } + + await this.gitService.initializeGit(); + await this.gitService.createGitignore(); + } + + // Ask if user wants to set remote + const shouldSetRemote = await this.promptService.askSetRemote(remoteUrl); + if (shouldSetRemote) { + await this.gitService.addRemote(remoteUrl); + } + + // Ask if user wants to push + const shouldPush = await this.promptService.askPushRepo(); + if (!shouldPush) { + console.log('ℹ️ Skipping push to remote'); + return; + } + + // Check if we have commits + const hasCommits = await this.gitService.hasCommits(); + if (!hasCommits) { + // Check if we have changes to commit + const hasChanges = await this.gitService.hasChanges(); + if (hasChanges) { + const shouldCommit = await this.promptService.askCommitChanges(); + if (shouldCommit) { + const message = await this.promptService.askCommitMessage(); + await this.gitService.addAndCommit(message); + } else { + console.log('ℹ️ Skipping commit and push (no commits to push)'); + return; + } + } else { + console.log('ℹ️ No changes to commit and push'); + return; + } + } + + // Push to remote + await this.gitService.push(); + console.log('🎉 All done!'); + } +} + +/** + * Main entry point for the CLI application + */ +async function main(): Promise { + // Set up prompt cancellation handler + const promptService = new PromptService(); + process.on('SIGINT', () => promptService.onCancel()); + + // Check if user just wants help or config + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h') || args.length === 0) { + const cliService = new CliService(); + cliService.help(); + return; + } + + // Handle config option early (before requiring repository name) + if (args.includes('--config') || args.includes('-c')) { + const { GlobalConfig } = await import('./global-config.js'); + console.log(GlobalConfig.getSetupInstructions()); + return; + } + + const app = new App(); + await app.run(); +} + +// Run the application - handles both direct execution and npm global installation +// Check if this file is being executed as the main module +const isMainModule = import.meta.url === `file://${process.argv[1]}` || + process.argv[1]?.includes('gitea-creator'); + +if (isMainModule) { + main().catch((error) => { + console.error('❌ Unexpected error:', error); + process.exit(1); + }); +} \ No newline at end of file diff --git a/src/prompts.ts b/src/prompts.ts new file mode 100644 index 0000000..adb247a --- /dev/null +++ b/src/prompts.ts @@ -0,0 +1,90 @@ +import prompts from 'prompts'; + +/** + * Interactive prompt service for user input + */ +export class PromptService { + /** + * Ask user if they want to initialize git + * @returns true if user wants to initialize git, false otherwise + */ + async askInitializeGit(): Promise { + const response = await prompts({ + type: 'confirm', + name: 'initGit', + message: 'No git repository found. Would you like to initialize one?', + initial: true, + }); + + return response.initGit; + } + + /** + * Ask user if they want to set the repository as the remote origin + * @param remoteUrl - The remote URL that will be set + * @returns true if user wants to set remote, false otherwise + */ + async askSetRemote(remoteUrl: string): Promise { + const response = await prompts({ + type: 'confirm', + name: 'setRemote', + message: `Set ${remoteUrl} as the remote origin?`, + initial: true, + }); + + return response.setRemote; + } + + /** + * Ask user if they want to push the current repository + * @returns true if user wants to push, false otherwise + */ + async askPushRepo(): Promise { + const response = await prompts({ + type: 'confirm', + name: 'pushRepo', + message: 'Push the current repository to the remote?', + initial: true, + }); + + return response.pushRepo; + } + + /** + * Ask user if they want to add and commit current changes before pushing + * @returns true if user wants to commit changes, false otherwise + */ + async askCommitChanges(): Promise { + const response = await prompts({ + type: 'confirm', + name: 'commitChanges', + message: 'You have uncommitted changes. Would you like to commit them first?', + initial: true, + }); + + return response.commitChanges; + } + + /** + * Ask user for a commit message + * @returns the commit message + */ + async askCommitMessage(): Promise { + const response = await prompts({ + type: 'text', + name: 'message', + message: 'Enter commit message:', + initial: 'Initial commit', + }); + + return response.message || 'Initial commit'; + } + + /** + * Handle the case when prompts are cancelled (Ctrl+C) + */ + onCancel() { + console.log('\n❌ Operation cancelled by user'); + process.exit(0); + } +} \ No newline at end of file diff --git a/src/repo.ts b/src/repo.ts new file mode 100644 index 0000000..c70b95e --- /dev/null +++ b/src/repo.ts @@ -0,0 +1,60 @@ +import { Config } from "./config.js"; + +export type InitializationOptions = { + name: string; + description: string; + private: boolean; +} + +/** + * Gitea repository response type + */ +export type GiteaRepository = { + id: number; + name: string; + full_name: string; + description: string; + private: boolean; + clone_url: string; + ssh_url: string; + html_url: string; + owner: { + login: string; + full_name: string; + }; +} + +/** + * Create a new repository on Gitea + */ +export class Repo { + static async create(config: Config, options: Partial = {}): Promise { + const { name, description, private: isPrivate } = options; + + const response = await fetch(`${config.apiUrl}/user/repos`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `token ${config.token}`, + }, + body: JSON.stringify({ + name, + description, + private: isPrivate, + }), + }); + + if (!response.ok) { + let errorMessage = `HTTP ${response.status}`; + try { + const error = await response.json() as { message?: string }; + errorMessage = error.message || errorMessage; + } catch { + // Ignore JSON parsing errors, use status code + } + throw new Error(`Failed to create repository (${response.status}): ${errorMessage}`); + } + + return response.json() as Promise; + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ee067a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,41 @@ +{ + // Visit https://aka.ms/tsconfig to read more about this file + "compilerOptions": { + // File Layout + "rootDir": "./src", + "outDir": "./dist", + + // Environment Settings + // See also https://aka.ms/tsconfig/module + "module": "nodenext", + "target": "esnext", + "lib": ["esnext"], + "types": ["node"], + + // Other Outputs + "sourceMap": true, + "declaration": true, + "declarationMap": true, + + // Stricter Typechecking Options + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + + // Style Options + // "noImplicitReturns": true, + // "noImplicitOverride": true, + // "noUnusedLocals": true, + // "noUnusedParameters": true, + // "noFallthroughCasesInSwitch": true, + // "noPropertyAccessFromIndexSignature": true, + + // Recommended Options + "strict": true, + "jsx": "react-jsx", + "verbatimModuleSyntax": true, + "isolatedModules": true, + "moduleDetection": "force", + "skipLibCheck": true, + }, + "include": ["src/**/*.ts"] +}