Skip to content

Commit 8961f8c

Browse files
refactor: modularize architecture for improved maintainability (#67)
Extract monolithic index.js (395 lines) into modular architecture: - src/cli/: Command handlers with dependency injection - src/config/: ConfigManager class (single source of truth) - src/services/: Git, OpenAI, prompt services with custom errors - src/core/: Business logic (commitGenerator, messageBuilder) - src/utils/: Pure utility functions Key improvements: - Eliminate 4 global state variables - Enable proper unit testing (26 → 57 tests) - Reduce index.js to 21 lines (thin entry point) - Add custom error classes for better error handling
1 parent 2e3f684 commit 8961f8c

30 files changed

+1354
-446
lines changed

index.js

Lines changed: 11 additions & 384 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"files": [
2020
"README.md",
2121
"index.js",
22+
"src",
2223
"utils"
2324
],
2425
"author": "Ryota Murakami <dojce1048@gmail.com> (https://github.com/ryota-murakami)",

src/cli/commands/apiKey.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { select, password, confirm } from '../../services/prompt.js'
2+
import { maskApiKey } from '../../utils/maskApiKey.js'
3+
4+
/**
5+
* Create the API key management command handler
6+
* @param {object} deps - Dependencies
7+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
8+
* @returns {Function} The command handler
9+
*/
10+
export function createApiKeyCommand({ config }) {
11+
return async function apiKeyAction() {
12+
const actionResponse = await select(
13+
'What would you like to do with your OpenAI API key?',
14+
[
15+
{ title: 'Add or update API key', value: 'add' },
16+
{ title: 'Show API key (masked)', value: 'show' },
17+
{ title: 'Delete API key', value: 'delete' },
18+
],
19+
0,
20+
)
21+
22+
if (!actionResponse) {
23+
console.log('Action cancelled.')
24+
return
25+
}
26+
27+
switch (actionResponse) {
28+
case 'add': {
29+
const inputKey = await password('Enter your OpenAI API key')
30+
if (inputKey) {
31+
config.set('apiKey', inputKey)
32+
console.log('API key saved to configuration.')
33+
} else {
34+
console.log('Action cancelled.')
35+
}
36+
break
37+
}
38+
39+
case 'delete': {
40+
const confirmDelete = await confirm(
41+
'Are you sure you want to delete your stored API key?',
42+
false,
43+
)
44+
if (confirmDelete) {
45+
config.delete('apiKey')
46+
console.log('API key deleted from configuration.')
47+
} else {
48+
console.log('Action cancelled.')
49+
}
50+
break
51+
}
52+
53+
case 'show': {
54+
const currentKey = config.get('apiKey')
55+
console.log(`OpenAI API key: ${maskApiKey(currentKey)}`)
56+
break
57+
}
58+
}
59+
}
60+
}

src/cli/commands/commit.js

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import path from 'path'
2+
import process from 'process'
3+
4+
import { generateCommitMessage } from '../../core/commitGenerator.js'
5+
import { NoChangesError } from '../../services/errors.js'
6+
import { getStagedDiff, executeCommit } from '../../services/git.js'
7+
import * as openaiService from '../../services/openai.js'
8+
import { confirm } from '../../services/prompt.js'
9+
10+
/**
11+
* Load API key from environment if not in config
12+
* @param {string|null} configApiKey - API key from config
13+
* @returns {Promise<string|null>} The API key
14+
*/
15+
async function resolveApiKey(configApiKey) {
16+
if (configApiKey) return configApiKey
17+
18+
// Try to load from .env
19+
const dotenv = await import('dotenv')
20+
const envPath = path.join(process.cwd(), '.env')
21+
dotenv.config({ path: envPath })
22+
23+
return process.env.OPENAI_API_KEY || null
24+
}
25+
26+
/**
27+
* Create the commit command handler
28+
* @param {object} deps - Dependencies
29+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
30+
* @returns {Function} The command handler
31+
*/
32+
export function createCommitCommand({ config }) {
33+
return async function commitAction() {
34+
try {
35+
// Get staged diff
36+
const diff = await getStagedDiff()
37+
38+
// Resolve API key
39+
const apiKey = await resolveApiKey(config.get('apiKey'))
40+
if (!apiKey) {
41+
console.error(
42+
'No OpenAI API key found. Please set it using "git gpt open-api-key add".',
43+
)
44+
process.exit(1)
45+
}
46+
47+
// Create OpenAI client
48+
const client = openaiService.createClient(apiKey)
49+
50+
// Generate commit message
51+
const message = await generateCommitMessage(
52+
{ openaiService, client },
53+
{
54+
diff,
55+
model: config.get('model'),
56+
language: config.get('language'),
57+
prefixEnabled: config.get('prefixEnabled'),
58+
},
59+
)
60+
61+
// Confirm with user
62+
const confirmed = await confirm(`${message}.`)
63+
if (confirmed) {
64+
executeCommit(message)
65+
console.log('Committed with the suggested message.')
66+
} else {
67+
console.log('Commit canceled.')
68+
}
69+
} catch (error) {
70+
if (error instanceof NoChangesError) {
71+
console.log('No changes to commit. Commit canceled.')
72+
process.exit(0)
73+
}
74+
console.error(error.message)
75+
process.exit(1)
76+
}
77+
}
78+
}

src/cli/commands/config.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { maskApiKey } from '../../utils/maskApiKey.js'
2+
3+
/**
4+
* Create the config display command handler
5+
* @param {object} deps - Dependencies
6+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
7+
* @returns {Function} The command handler
8+
*/
9+
export function createConfigCommand({ config }) {
10+
return function configAction() {
11+
const prefixEnabled = config.get('prefixEnabled')
12+
const model = config.get('model')
13+
const language = config.get('language')
14+
const apiKey = config.get('apiKey')
15+
const configPath = config.getPath()
16+
17+
console.log(` prefix: ${prefixEnabled ? 'enabled' : 'disabled'}`)
18+
console.log(` model: ${model}`)
19+
console.log(` lang: ${language}`)
20+
console.log(` apikey: ${maskApiKey(apiKey)}`)
21+
console.log(` path: ${configPath}`)
22+
}
23+
}

src/cli/commands/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { createCommitCommand } from './commit.js'
2+
export { createModelCommand } from './model.js'
3+
export { createLangCommand } from './lang.js'
4+
export { createPrefixCommand } from './prefix.js'
5+
export { createApiKeyCommand } from './apiKey.js'
6+
export { createConfigCommand } from './config.js'

src/cli/commands/lang.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { availableLanguages } from '../../config/defaults.js'
2+
import { select } from '../../services/prompt.js'
3+
4+
/**
5+
* Create the language selection command handler
6+
* @param {object} deps - Dependencies
7+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
8+
* @returns {Function} The command handler
9+
*/
10+
export function createLangCommand({ config }) {
11+
return async function langAction() {
12+
const selectedLanguage = await select(
13+
'Select a language for commit messages',
14+
availableLanguages,
15+
0,
16+
)
17+
18+
if (selectedLanguage) {
19+
config.set('language', selectedLanguage)
20+
console.log(
21+
`Language set to ${selectedLanguage} and saved to configuration`,
22+
)
23+
}
24+
}
25+
}

src/cli/commands/model.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { availableModels } from '../../config/defaults.js'
2+
import { select } from '../../services/prompt.js'
3+
4+
/**
5+
* Create the model selection command handler
6+
* @param {object} deps - Dependencies
7+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
8+
* @returns {Function} The command handler
9+
*/
10+
export function createModelCommand({ config }) {
11+
return async function modelAction() {
12+
const selectedModel = await select('Select a model', availableModels, 0)
13+
14+
if (selectedModel) {
15+
config.set('model', selectedModel)
16+
console.log(`Model set to ${selectedModel} and saved to configuration`)
17+
}
18+
}
19+
}

src/cli/commands/prefix.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { select } from '../../services/prompt.js'
2+
3+
/**
4+
* Create the prefix toggle command handler
5+
* @param {object} deps - Dependencies
6+
* @param {import('../../config/index.js').ConfigManager} deps.config - Config manager
7+
* @returns {Function} The command handler
8+
*/
9+
export function createPrefixCommand({ config }) {
10+
return async function prefixAction() {
11+
const currentEnabled = config.get('prefixEnabled')
12+
13+
// Show current state
14+
console.log(
15+
`Prefixes are currently ${currentEnabled ? 'enabled' : 'disabled'}.`,
16+
)
17+
18+
const choices = [
19+
{ title: 'Enable prefixes', value: true },
20+
{ title: 'Disable prefixes', value: false },
21+
]
22+
23+
const selectedValue = await select(
24+
'Set commit message prefixes (e.g., fix:, feat:, refactor:)',
25+
choices,
26+
currentEnabled ? 0 : 1,
27+
)
28+
29+
if (selectedValue !== undefined) {
30+
config.set('prefixEnabled', selectedValue)
31+
console.log(
32+
`Prefix ${selectedValue ? 'enabled' : 'disabled'} and saved to configuration`,
33+
)
34+
}
35+
}
36+
}

src/cli/index.js

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { program } from 'commander'
2+
3+
import { ConfigManager } from '../config/index.js'
4+
5+
import {
6+
createCommitCommand,
7+
createModelCommand,
8+
createLangCommand,
9+
createPrefixCommand,
10+
createApiKeyCommand,
11+
createConfigCommand,
12+
} from './commands/index.js'
13+
14+
/**
15+
* Setup and run the CLI
16+
* @param {object} [options] - Options
17+
* @param {ConfigManager} [options.config] - Config manager instance (for testing)
18+
*/
19+
export function setupCli(options = {}) {
20+
// Use provided config or create new one
21+
const config = options.config || new ConfigManager()
22+
23+
// Load configuration at startup
24+
config.load()
25+
26+
// Create command handlers with dependencies
27+
const deps = { config }
28+
29+
// Register commands
30+
program
31+
.command('commit')
32+
.description(
33+
'Generate a Git commit message based on the summary of changes',
34+
)
35+
.action(createCommitCommand(deps))
36+
37+
program
38+
.command('model')
39+
.description('Select the model to use')
40+
.action(createModelCommand(deps))
41+
42+
program
43+
.command('lang')
44+
.description('Select the commit message language')
45+
.action(createLangCommand(deps))
46+
47+
program
48+
.command('prefix')
49+
.description('Toggle commit message prefix (e.g., fix:, feat:, refactor:)')
50+
.action(createPrefixCommand(deps))
51+
52+
program
53+
.command('open-api-key')
54+
.description('Manage your OpenAI API key')
55+
.action(createApiKeyCommand(deps))
56+
57+
program
58+
.command('config')
59+
.description('Show current configuration')
60+
.action(createConfigCommand(deps))
61+
62+
// Handle invalid commands
63+
program.on('command:*', () => {
64+
console.error('Invalid command: %s\n', program.args.join(' '))
65+
program.help()
66+
})
67+
68+
return program
69+
}
70+
71+
/**
72+
* Main entry point for the CLI
73+
* @param {string[]} [args] - Command line arguments
74+
*/
75+
export function runCli(args = process.argv) {
76+
const program = setupCli()
77+
program.parse(args)
78+
}
79+
80+
export default runCli

0 commit comments

Comments
 (0)