e-pick 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js ADDED
@@ -0,0 +1,289 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * CLI Entry Point for E-Pick Tool
5
+ *
6
+ * Provides a command-line interface for starting the web server
7
+ * with support for repository management via subcommands.
8
+ */
9
+
10
+ import { Command } from 'commander';
11
+ import chalk from 'chalk';
12
+ import open from 'open';
13
+ import { resolve, isAbsolute, dirname } from 'path';
14
+ import { existsSync, statSync } from 'fs';
15
+ import { execSync } from 'child_process';
16
+ import { fileURLToPath } from 'url';
17
+ import appConfig from './config/app.config.js';
18
+ import repoManager from './config/repo-manager.js';
19
+
20
+ const __filename = fileURLToPath(import.meta.url);
21
+ const __dirname = dirname(__filename);
22
+
23
+ const program = new Command();
24
+
25
+ /**
26
+ * Check if a path is a valid git repository
27
+ * @param {string} path - Path to check
28
+ * @returns {boolean} True if valid git repo
29
+ */
30
+ function isGitRepository(path) {
31
+ try {
32
+ execSync('git rev-parse --git-dir', {
33
+ cwd: path,
34
+ stdio: 'pipe',
35
+ });
36
+ return true;
37
+ } catch (error) {
38
+ return false;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Validate repository path
44
+ * @param {string} path - Path to validate
45
+ * @returns {string} Absolute path if valid
46
+ * @throws {Error} If path is invalid
47
+ */
48
+ function validateRepoPath(path) {
49
+ const absolutePath = isAbsolute(path) ? path : resolve(process.cwd(), path);
50
+
51
+ if (!existsSync(absolutePath)) {
52
+ throw new Error(`Path does not exist: ${absolutePath}`);
53
+ }
54
+
55
+ const stats = statSync(absolutePath);
56
+ if (!stats.isDirectory()) {
57
+ throw new Error(`Path is not a directory: ${absolutePath}`);
58
+ }
59
+
60
+ if (!isGitRepository(absolutePath)) {
61
+ throw new Error(`Path is not a git repository: ${absolutePath}\n\nPlease initialize git or specify a valid git repository.`);
62
+ }
63
+
64
+ return absolutePath;
65
+ }
66
+
67
+ /**
68
+ * Get repository name from path
69
+ * @param {string} repoPath - Repository path
70
+ * @returns {string} Repository name
71
+ */
72
+ function getRepoName(repoPath) {
73
+ try {
74
+ const remoteName = execSync('git config --get remote.origin.url', {
75
+ cwd: repoPath,
76
+ stdio: 'pipe',
77
+ encoding: 'utf-8',
78
+ }).trim();
79
+
80
+ const match = remoteName.match(/\/([^\/]+?)(?:\.git)?$/);
81
+ if (match) {
82
+ return match[1];
83
+ }
84
+ } catch (error) {
85
+ // No remote, use directory name
86
+ }
87
+
88
+ return repoPath.split('/').filter(Boolean).pop();
89
+ }
90
+
91
+ /**
92
+ * Display startup banner
93
+ */
94
+ function displayBanner(repoPath, repoName, port) {
95
+ console.log();
96
+ console.log(chalk.cyan.bold('🚀 E-Pick Server'));
97
+ console.log();
98
+ console.log(chalk.gray('Repository:'), chalk.white.bold(repoName));
99
+ console.log(chalk.gray('Path:'), chalk.white(repoPath));
100
+ console.log(chalk.gray('Server:'), chalk.green.bold(`http://localhost:${port}`));
101
+ console.log();
102
+ console.log(chalk.yellow('Press Ctrl+C to stop'));
103
+ console.log();
104
+ }
105
+
106
+ /**
107
+ * Main CLI function
108
+ */
109
+ async function main() {
110
+ // ====================
111
+ // REPO SUBCOMMAND
112
+ // ====================
113
+ const repoCmd = program
114
+ .command('repo')
115
+ .description('Manage saved repositories');
116
+
117
+ // repo list
118
+ repoCmd
119
+ .command('list')
120
+ .description('List all saved repositories')
121
+ .action(() => {
122
+ const repos = repoManager.listRepos();
123
+ const entries = Object.entries(repos);
124
+
125
+ if (entries.length === 0) {
126
+ console.log(chalk.yellow('No saved repositories'));
127
+ process.exit(0);
128
+ }
129
+
130
+ console.log();
131
+ console.log(chalk.cyan.bold('📁 Saved Repositories'));
132
+ console.log();
133
+
134
+ entries.forEach(([alias, path]) => {
135
+ console.log(chalk.white.bold(alias), chalk.gray('→'), chalk.gray(path));
136
+ });
137
+
138
+ console.log();
139
+ console.log(chalk.gray(`Total: ${entries.length} repositor${entries.length === 1 ? 'y' : 'ies'}`));
140
+ console.log();
141
+ });
142
+
143
+ // repo add
144
+ repoCmd
145
+ .command('add <alias> [path]')
146
+ .description('Save a repository with an alias')
147
+ .action((alias, path) => {
148
+ const repoPath = path || process.cwd();
149
+
150
+ try {
151
+ const absolutePath = validateRepoPath(repoPath);
152
+ repoManager.saveRepo(alias, absolutePath);
153
+
154
+ console.log(chalk.green(`✓ Saved repository: ${alias}`));
155
+ console.log(chalk.gray(` Path: ${absolutePath}`));
156
+ } catch (error) {
157
+ console.error(chalk.red('✗'), error.message);
158
+ process.exit(1);
159
+ }
160
+ });
161
+
162
+ // repo remove
163
+ repoCmd
164
+ .command('remove <alias>')
165
+ .description('Remove a saved repository')
166
+ .action((alias) => {
167
+ const success = repoManager.removeRepo(alias);
168
+
169
+ if (success) {
170
+ console.log(chalk.green(`✓ Removed repository: ${alias}`));
171
+ } else {
172
+ console.log(chalk.red(`✗ Repository not found: ${alias}`));
173
+ process.exit(1);
174
+ }
175
+ });
176
+
177
+
178
+ // ====================
179
+ // MAIN COMMAND - Start Server
180
+ // ====================
181
+ program
182
+ .name('epick')
183
+ .description('Web-based tool for cherry-picking git commits from Excel/CSV files')
184
+ .version('3.0.0')
185
+ .argument('[repository]', 'Repository alias or path (default: current directory)')
186
+ .option('-p, --port <number>', 'Port to run server on (default: 7991)', parseInt)
187
+ .option('--no-open', 'Do not automatically open browser')
188
+ .action(async (repository, options) => {
189
+ let repoPath = repository || process.cwd();
190
+ let resolvedFromAlias = false;
191
+
192
+ // Check if repository is an alias
193
+ if (repository) {
194
+ const savedPath = repoManager.getRepoPath(repository);
195
+ if (savedPath) {
196
+ repoPath = savedPath;
197
+ resolvedFromAlias = true;
198
+ console.log(chalk.gray(`Using saved repository: ${repository}`));
199
+ }
200
+ }
201
+
202
+ // Validate repository
203
+ console.log(chalk.gray('Validating repository...'));
204
+
205
+ let validatedPath;
206
+ try {
207
+ validatedPath = validateRepoPath(repoPath);
208
+ appConfig.setRepoPath(validatedPath);
209
+ console.log(chalk.green('✓ Repository validated'));
210
+ } catch (error) {
211
+ console.error();
212
+ console.error(chalk.red('✗ Error:'), error.message);
213
+ console.error();
214
+
215
+ if (error.message.includes('not a git repository')) {
216
+ console.error(chalk.gray('Tip: Make sure you are in a git repository or provide a valid path'));
217
+ console.error(chalk.gray('Example: epick /path/to/your/repo'));
218
+ }
219
+
220
+ process.exit(1);
221
+ }
222
+
223
+ // Get repository name
224
+ const repoName = getRepoName(validatedPath);
225
+
226
+ // Set port
227
+ const port = options.port || appConfig.getServerConfig().defaultPort;
228
+
229
+ // Start server
230
+ console.log(chalk.gray('Starting server...'));
231
+
232
+ const { startServer } = await import('./server.js');
233
+ const { server, actualPort } = await startServer({
234
+ repoPath: validatedPath,
235
+ port,
236
+ });
237
+
238
+ console.log(chalk.green(`✓ Server started on port ${actualPort}`));
239
+
240
+ // Auto-save repository if not already saved
241
+ if (!resolvedFromAlias && repository && repository !== process.cwd()) {
242
+ repoManager.saveRepo(getRepoName(validatedPath), validatedPath);
243
+ console.log(chalk.gray(`Auto-saved as: ${getRepoName(validatedPath)}`));
244
+ }
245
+
246
+ // Display banner
247
+ displayBanner(validatedPath, repoName, actualPort);
248
+
249
+ // Auto-open browser
250
+ if (options.open !== false) {
251
+ try {
252
+ await open(`http://localhost:${actualPort}`);
253
+ } catch (error) {
254
+ console.log(chalk.gray('Note: Could not auto-open browser'));
255
+ }
256
+ }
257
+
258
+ // Handle graceful shutdown
259
+ const shutdown = async () => {
260
+ console.log();
261
+ console.log(chalk.yellow('Shutting down server...'));
262
+
263
+ if (server) {
264
+ server.close(() => {
265
+ console.log(chalk.green('Server stopped'));
266
+ process.exit(0);
267
+ });
268
+
269
+ setTimeout(() => {
270
+ console.log(chalk.red('Forcing shutdown...'));
271
+ process.exit(1);
272
+ }, 5000);
273
+ } else {
274
+ process.exit(0);
275
+ }
276
+ };
277
+
278
+ process.on('SIGINT', shutdown);
279
+ process.on('SIGTERM', shutdown);
280
+ });
281
+
282
+ await program.parseAsync(process.argv);
283
+ }
284
+
285
+ // Run CLI
286
+ main().catch((error) => {
287
+ console.error(chalk.red('Fatal error:'), error.message);
288
+ process.exit(1);
289
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Cherry-Pick Command (Command Pattern)
3
+ *
4
+ * Encapsulates cherry-pick operations as command objects.
5
+ */
6
+
7
+ class CherryPickCommand {
8
+ constructor(gitService, commits, options = {}) {
9
+ this.gitService = gitService;
10
+ this.commits = commits;
11
+ this.options = options;
12
+ }
13
+
14
+ /**
15
+ * Execute the cherry-pick command
16
+ * @returns {object} Execution result
17
+ */
18
+ execute() {
19
+ try {
20
+ // Generate the command
21
+ const command = this.gitService.generateCherryPickCommand(
22
+ this.commits,
23
+ this.options,
24
+ );
25
+
26
+ // Get sorted commits with info
27
+ const sortedCommits = this.gitService.sortCommitsByDate(this.commits);
28
+ const commitsWithInfo = sortedCommits.map((hash) => {
29
+ const info = this.gitService.getCommitInfo(hash);
30
+ return info || { hash, message: 'Unknown', date: null };
31
+ });
32
+
33
+ // Get list of changed files
34
+ const changedFiles = this.gitService.getChangedFiles(this.commits);
35
+
36
+ return {
37
+ success: true,
38
+ command,
39
+ commits: commitsWithInfo,
40
+ changedFiles,
41
+ warning: this._generateWarnings(),
42
+ };
43
+ } catch (error) {
44
+ return {
45
+ success: false,
46
+ error: error.message,
47
+ command: null,
48
+ commits: [],
49
+ changedFiles: [],
50
+ };
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Generate warnings if needed
56
+ * @returns {string|null} Warning message
57
+ * @private
58
+ */
59
+ _generateWarnings() {
60
+ if (this.commits.length > 50) {
61
+ return `You are cherry-picking ${this.commits.length} commits. This may take a while and could result in conflicts.`;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Undo the command (for future implementation)
68
+ * @returns {object} Undo result
69
+ */
70
+ undo() {
71
+ // Future: Implement cherry-pick abort
72
+ return {
73
+ success: false,
74
+ message: 'Undo not yet implemented',
75
+ };
76
+ }
77
+ }
78
+
79
+ export default CherryPickCommand;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Application Configuration (Singleton Pattern)
3
+ *
4
+ * Centralized configuration management for the application.
5
+ * Ensures a single source of truth for all configuration values.
6
+ */
7
+
8
+ class AppConfig {
9
+ constructor() {
10
+ if (AppConfig.instance) {
11
+ return AppConfig.instance;
12
+ }
13
+
14
+ // Server configuration
15
+ this.server = {
16
+ defaultPort: 7991,
17
+ fallbackPortRange: {
18
+ min: 8000,
19
+ max: 9000,
20
+ },
21
+ host: 'localhost',
22
+ };
23
+
24
+ // Git configuration
25
+ this.git = {
26
+ timeout: 30000, // 30 seconds
27
+ maxCommitsPerBatch: 100,
28
+ };
29
+
30
+ // CORS configuration
31
+ this.cors = {
32
+ origin: '*',
33
+ methods: ['GET', 'POST'],
34
+ allowedHeaders: ['Content-Type'],
35
+ };
36
+
37
+ // Logging configuration
38
+ this.logging = {
39
+ level: process.env.LOG_LEVEL || 'info',
40
+ file: {
41
+ enabled: false,
42
+ path: 'logs/app.log',
43
+ },
44
+ };
45
+
46
+ // Repository configuration (set by CLI)
47
+ this.repository = {
48
+ path: null, // Will be set by CLI
49
+ };
50
+
51
+ AppConfig.instance = this;
52
+ }
53
+
54
+ /**
55
+ * Set the repository path
56
+ * @param {string} path - Full path to the git repository
57
+ */
58
+ setRepoPath(path) {
59
+ this.repository.path = path;
60
+ }
61
+
62
+ /**
63
+ * Get the repository path
64
+ * @returns {string|null} Repository path
65
+ */
66
+ getRepoPath() {
67
+ return this.repository.path;
68
+ }
69
+
70
+ /**
71
+ * Get server configuration
72
+ * @returns {object} Server configuration
73
+ */
74
+ getServerConfig() {
75
+ return { ...this.server };
76
+ }
77
+
78
+ /**
79
+ * Get git configuration
80
+ * @returns {object} Git configuration
81
+ */
82
+ getGitConfig() {
83
+ return { ...this.git };
84
+ }
85
+
86
+ /**
87
+ * Get CORS configuration
88
+ * @returns {object} CORS configuration
89
+ */
90
+ getCorsConfig() {
91
+ return { ...this.cors };
92
+ }
93
+
94
+ /**
95
+ * Get logging configuration
96
+ * @returns {object} Logging configuration
97
+ */
98
+ getLoggingConfig() {
99
+ return { ...this.logging };
100
+ }
101
+
102
+ /**
103
+ * Get singleton instance
104
+ * @returns {AppConfig} AppConfig instance
105
+ */
106
+ static getInstance() {
107
+ if (!AppConfig.instance) {
108
+ AppConfig.instance = new AppConfig();
109
+ }
110
+ return AppConfig.instance;
111
+ }
112
+ }
113
+
114
+ // Export singleton instance
115
+ export default AppConfig.getInstance();
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Repository Configuration Manager
3
+ * Manages saved repository paths and aliases
4
+ */
5
+
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import os from 'os';
9
+
10
+ class RepoManager {
11
+ constructor() {
12
+ this.configDir = path.join(os.homedir(), '.e-pick');
13
+ this.configFile = path.join(this.configDir, 'repos.json');
14
+ this.ensureConfigExists();
15
+ }
16
+
17
+ /**
18
+ * Ensure config directory and file exist
19
+ */
20
+ ensureConfigExists() {
21
+ try {
22
+ if (!fs.existsSync(this.configDir)) {
23
+ fs.mkdirSync(this.configDir, { recursive: true });
24
+ }
25
+
26
+ if (!fs.existsSync(this.configFile)) {
27
+ fs.writeFileSync(this.configFile, JSON.stringify({}, null, 2));
28
+ }
29
+ } catch (error) {
30
+ throw new Error(`Failed to create config directory: ${error.message}`);
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Load all saved repositories
36
+ * @returns {object} Map of alias → path
37
+ */
38
+ loadRepos() {
39
+ try {
40
+ const data = fs.readFileSync(this.configFile, 'utf8');
41
+ return JSON.parse(data);
42
+ } catch (error) {
43
+ return {};
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Save repositories to config file
49
+ * @param {object} repos - Map of alias → path
50
+ */
51
+ saveRepos(repos) {
52
+ try {
53
+ fs.writeFileSync(this.configFile, JSON.stringify(repos, null, 2));
54
+ } catch (error) {
55
+ throw new Error(`Failed to save repos: ${error.message}`);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Save a repository with an alias
61
+ * @param {string} alias - Repository alias
62
+ * @param {string} repoPath - Absolute path to repository
63
+ * @returns {boolean} Success
64
+ */
65
+ saveRepo(alias, repoPath) {
66
+ const repos = this.loadRepos();
67
+ const absolutePath = path.resolve(repoPath);
68
+
69
+ repos[alias] = absolutePath;
70
+ this.saveRepos(repos);
71
+
72
+ return true;
73
+ }
74
+
75
+ /**
76
+ * Get repository path by alias
77
+ * @param {string} alias - Repository alias
78
+ * @returns {string|null} Repository path or null if not found
79
+ */
80
+ getRepoPath(alias) {
81
+ const repos = this.loadRepos();
82
+ return repos[alias] || null;
83
+ }
84
+
85
+ /**
86
+ * Remove a saved repository
87
+ * @param {string} alias - Repository alias
88
+ * @returns {boolean} Success
89
+ */
90
+ removeRepo(alias) {
91
+ const repos = this.loadRepos();
92
+
93
+ if (!repos[alias]) {
94
+ return false;
95
+ }
96
+
97
+ delete repos[alias];
98
+ this.saveRepos(repos);
99
+
100
+ return true;
101
+ }
102
+
103
+ /**
104
+ * List all saved repositories
105
+ * @returns {object} Map of alias → path
106
+ */
107
+ listRepos() {
108
+ return this.loadRepos();
109
+ }
110
+
111
+ /**
112
+ * Check if a repository alias exists
113
+ * @param {string} alias - Repository alias
114
+ * @returns {boolean}
115
+ */
116
+ hasRepo(alias) {
117
+ const repos = this.loadRepos();
118
+ return alias in repos;
119
+ }
120
+
121
+ /**
122
+ * Get repository name from path
123
+ * @param {string} repoPath - Repository path
124
+ * @returns {string} Repository name (folder name)
125
+ */
126
+ getRepoNameFromPath(repoPath) {
127
+ return path.basename(repoPath);
128
+ }
129
+ }
130
+
131
+ export default new RepoManager();
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Commit Controller
3
+ *
4
+ * Handles API requests for commit validation and cherry-pick operations
5
+ */
6
+
7
+ import appConfig from '../config/app.config.js';
8
+ import ValidationService from '../services/validation.service.js';
9
+ import GitService from '../services/git.service.js';
10
+ import CherryPickCommand from '../commands/cherry-pick.command.js';
11
+ import { asyncHandler } from '../utils/error-handler.js';
12
+
13
+ /**
14
+ * POST /api/validate
15
+ * Validate array of commit hashes
16
+ */
17
+ export const validateCommits = asyncHandler(async (req, res) => {
18
+ const { commits } = req.body;
19
+ const repoPath = appConfig.getRepoPath();
20
+
21
+ const validationService = new ValidationService(repoPath);
22
+ const result = await validationService.validateCommits(commits);
23
+
24
+ res.json({
25
+ success: true,
26
+ ...result,
27
+ });
28
+ });
29
+
30
+ /**
31
+ * POST /api/generate-command
32
+ * Generate cherry-pick command from validated commits
33
+ */
34
+ export const generateCommand = asyncHandler(async (req, res) => {
35
+ const { commits, ignoredCommits = [], options = {} } = req.body;
36
+ const repoPath = appConfig.getRepoPath();
37
+
38
+ // Filter out ignored commits
39
+ const commitsToProcess = commits.filter(
40
+ (commit) => !ignoredCommits.includes(commit),
41
+ );
42
+
43
+ if (commitsToProcess.length === 0) {
44
+ return res.status(400).json({
45
+ success: false,
46
+ error: 'ValidationError',
47
+ message: 'No commits to cherry-pick after filtering ignored commits',
48
+ });
49
+ }
50
+
51
+ const gitService = new GitService(repoPath);
52
+ const command = new CherryPickCommand(gitService, commitsToProcess, options);
53
+ const result = command.execute();
54
+
55
+ if (!result.success) {
56
+ return res.status(500).json({
57
+ success: false,
58
+ error: 'GitError',
59
+ message: result.error,
60
+ });
61
+ }
62
+
63
+ res.json({
64
+ success: true,
65
+ ...result,
66
+ });
67
+ });
68
+
69
+ /**
70
+ * GET /api/repo-info
71
+ * Get current repository information
72
+ */
73
+ export const getRepoInfo = asyncHandler(async (req, res) => {
74
+ const repoPath = appConfig.getRepoPath();
75
+ const gitService = new GitService(repoPath);
76
+
77
+ const repoInfo = gitService.getRepoInfo();
78
+
79
+ res.json({
80
+ success: true,
81
+ repo: repoInfo,
82
+ });
83
+ });
84
+
85
+ /**
86
+ * GET /api/check-commit/:commitHash
87
+ * Check if a commit exists in the repository
88
+ */
89
+ export const checkCommit = asyncHandler(async (req, res) => {
90
+ const { commitHash } = req.params;
91
+
92
+ const repoPath = appConfig.getRepoPath();
93
+ const gitService = new GitService(repoPath);
94
+
95
+ const exists = gitService.validateCommit(commitHash);
96
+
97
+ res.json({
98
+ success: true,
99
+ exists,
100
+ commit: commitHash,
101
+ });
102
+ });