@misaelabanto/commita 1.0.1

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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@misaelabanto/commita",
3
+ "version": "1.0.1",
4
+ "description": "AI-powered git auto-commit tool that intelligently groups your changes and generates meaningful commit messages",
5
+ "module": "index.ts",
6
+ "type": "module",
7
+ "private": false,
8
+ "bin": {
9
+ "commita": "./index.ts"
10
+ },
11
+ "scripts": {
12
+ "dev": "bun run index.ts",
13
+ "build": "bun build index.ts --outdir dist --target bun",
14
+ "commita": "bun run index.ts"
15
+ },
16
+ "author": "Misael Abanto",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/misaelabanto/commita.git"
21
+ },
22
+ "homepage": "https://github.com/misaelabanto/commita#readme",
23
+ "dependencies": {
24
+ "@ai-sdk/google": "^2.0.28",
25
+ "@ai-sdk/openai": "^2.0.62",
26
+ "ai": "^5.0.87",
27
+ "chalk": "^5.3.0",
28
+ "commander": "^12.1.0",
29
+ "dotenv": "^16.4.7",
30
+ "minimatch": "^10.0.1",
31
+ "simple-git": "^3.27.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/bun": "latest",
35
+ "@types/minimatch": "^5.1.2"
36
+ },
37
+ "peerDependencies": {
38
+ "typescript": "^5"
39
+ }
40
+ }
@@ -0,0 +1,124 @@
1
+ import { CommitTypeAnalyzer } from '@/ai/commit-type-analyzer.ts';
2
+ import { EmojiMapper } from '@/ai/emoji-mapper.ts';
3
+ import type { CommitaConfig } from '@/config/config.types.ts';
4
+ import { PROMPT_TEMPLATES } from '@/config/prompt-templates.ts';
5
+ import { openai, createOpenAI } from '@ai-sdk/openai';
6
+ import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
7
+ import { generateText } from 'ai';
8
+
9
+ export class AIService {
10
+ private config: CommitaConfig;
11
+ private typeAnalyzer: CommitTypeAnalyzer;
12
+ private emojiMapper: EmojiMapper;
13
+
14
+ constructor(config: CommitaConfig) {
15
+ this.config = config;
16
+ this.typeAnalyzer = new CommitTypeAnalyzer();
17
+ this.emojiMapper = new EmojiMapper();
18
+
19
+ this.validateConfig();
20
+ }
21
+
22
+ private validateConfig(): void {
23
+ if (this.config.provider === 'openai' && !this.config.openaiApiKey) {
24
+ throw new Error('OpenAI API key is required. Set it in .commita file or OPENAI_API_KEY env var.');
25
+ }
26
+
27
+ if (this.config.provider === 'gemini' && !this.config.geminiApiKey) {
28
+ throw new Error('Gemini API key is required. Set it in .commita file or GEMINI_API_KEY env var.');
29
+ }
30
+ }
31
+
32
+ async generateCommitMessage(diff: string, files: string[], scope: string): Promise<string> {
33
+ const prompt = this.buildPrompt(diff);
34
+
35
+ try {
36
+ const provider = this.getProvider();
37
+ const model = provider(this.config.model);
38
+
39
+ const { text } = await generateText({
40
+ model,
41
+ system: 'You are a helpful assistant that generates concise git commit messages. The response should be plain text without any markdown formatting.',
42
+ prompt,
43
+ temperature: 0.7,
44
+ maxTokens: 500,
45
+ });
46
+
47
+ let message = text.replace(/^```\n*((.*\n*)+)```$/, '$1').trim() || '';
48
+
49
+ if (!message) {
50
+ const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
51
+ message = `${commitType}(${scope}): update files`;
52
+ }
53
+
54
+ if (this.config.commitStyle === 'emoji') {
55
+ message = this.emojiMapper.replaceTypeWithEmoji(message);
56
+ }
57
+
58
+ return message;
59
+ } catch (error) {
60
+ console.error('Error generating commit message:', error);
61
+ const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
62
+ let fallbackMessage = `${commitType}(${scope}): update files`;
63
+
64
+ if (this.config.commitStyle === 'emoji') {
65
+ fallbackMessage = this.emojiMapper.replaceTypeWithEmoji(fallbackMessage);
66
+ }
67
+
68
+ return fallbackMessage;
69
+ }
70
+ }
71
+
72
+ private getProvider() {
73
+ if (this.config.provider === 'gemini') {
74
+ if (this.config.geminiApiKey) {
75
+ return createGoogleGenerativeAI({
76
+ apiKey: this.config.geminiApiKey,
77
+ });
78
+ }
79
+ return google;
80
+ }
81
+
82
+ if (this.config.openaiApiKey) {
83
+ return createOpenAI({
84
+ apiKey: this.config.openaiApiKey,
85
+ });
86
+ }
87
+
88
+ return openai;
89
+ }
90
+
91
+ private buildPrompt(diff: string): string {
92
+ let template: string;
93
+
94
+ if (this.config.promptStyle === 'custom') {
95
+ template = this.config.customPrompt || this.config.promptTemplate || PROMPT_TEMPLATES.default;
96
+ } else {
97
+ template = PROMPT_TEMPLATES[this.config.promptStyle];
98
+ }
99
+
100
+ return template.replace('{diff}', this.truncateDiff(diff));
101
+ }
102
+
103
+ private truncateDiff(diff: string, maxLength: number = 8000): string {
104
+ if (diff.length <= maxLength) {
105
+ return diff;
106
+ }
107
+
108
+ const lines = diff.split('\n');
109
+ const truncated: string[] = [];
110
+ let currentLength = 0;
111
+
112
+ for (const line of lines) {
113
+ if (currentLength + line.length > maxLength) {
114
+ truncated.push('\n... (diff truncated for brevity) ...');
115
+ break;
116
+ }
117
+ truncated.push(line);
118
+ currentLength += line.length + 1;
119
+ }
120
+
121
+ return truncated.join('\n');
122
+ }
123
+ }
124
+
@@ -0,0 +1,103 @@
1
+ export type CommitType = 'feat' | 'fix' | 'refactor' | 'chore' | 'docs' | 'style' | 'test' | 'perf';
2
+
3
+ export class CommitTypeAnalyzer {
4
+ analyzeFromDiff(diff: string, files: string[]): CommitType {
5
+ const lowerDiff = diff.toLowerCase();
6
+ const filePaths = files.map(f => f.toLowerCase());
7
+
8
+ if (this.isTest(filePaths, lowerDiff)) return 'test';
9
+ if (this.isDocs(filePaths, lowerDiff)) return 'docs';
10
+ if (this.isChore(filePaths, lowerDiff)) return 'chore';
11
+ if (this.isStyle(lowerDiff)) return 'style';
12
+ if (this.isPerf(lowerDiff)) return 'perf';
13
+ if (this.isFix(lowerDiff)) return 'fix';
14
+ if (this.isFeat(diff, lowerDiff)) return 'feat';
15
+
16
+ return 'refactor';
17
+ }
18
+
19
+ private isTest(files: string[], diff: string): boolean {
20
+ const testPatterns = ['.test.', '.spec.', '__tests__', '/tests/', '/test/'];
21
+ return files.some(f => testPatterns.some(p => f.includes(p))) ||
22
+ diff.includes('test(') || diff.includes('describe(') || diff.includes('it(');
23
+ }
24
+
25
+ private isDocs(files: string[], diff: string): boolean {
26
+ const docPatterns = ['readme', '.md', 'documentation', '/docs/'];
27
+ return files.some(f => docPatterns.some(p => f.includes(p)));
28
+ }
29
+
30
+ private isChore(files: string[], diff: string): boolean {
31
+ const chorePatterns = [
32
+ 'package.json',
33
+ 'package-lock.json',
34
+ 'yarn.lock',
35
+ 'bun.lock',
36
+ '.gitignore',
37
+ 'tsconfig',
38
+ 'webpack',
39
+ 'vite.config',
40
+ '.eslint',
41
+ '.prettier',
42
+ ];
43
+ return files.some(f => chorePatterns.some(p => f.includes(p)));
44
+ }
45
+
46
+ private isStyle(diff: string): boolean {
47
+ const styleKeywords = ['formatting', 'whitespace', 'indent', 'prettier', 'eslint'];
48
+ const hasStyleKeywords = styleKeywords.some(k => diff.includes(k));
49
+
50
+ const hasCodeChanges = diff.includes('function') ||
51
+ diff.includes('class') ||
52
+ diff.includes('const') ||
53
+ diff.includes('import');
54
+
55
+ return hasStyleKeywords && !hasCodeChanges;
56
+ }
57
+
58
+ private isPerf(diff: string): boolean {
59
+ const perfKeywords = [
60
+ 'performance',
61
+ 'optimize',
62
+ 'cache',
63
+ 'memoize',
64
+ 'debounce',
65
+ 'throttle',
66
+ 'lazy',
67
+ 'async',
68
+ 'usememo',
69
+ 'usecallback',
70
+ ];
71
+ return perfKeywords.some(k => diff.includes(k));
72
+ }
73
+
74
+ private isFix(diff: string): boolean {
75
+ const fixKeywords = [
76
+ 'fix',
77
+ 'bug',
78
+ 'issue',
79
+ 'error',
80
+ 'crash',
81
+ 'problem',
82
+ 'resolve',
83
+ 'correct',
84
+ 'patch',
85
+ 'hotfix',
86
+ ];
87
+ return fixKeywords.some(k => diff.includes(k));
88
+ }
89
+
90
+ private isFeat(diff: string, lowerDiff: string): boolean {
91
+ const hasNewFiles = diff.includes('new file mode');
92
+ const hasNewFunctions = lowerDiff.includes('+function') ||
93
+ lowerDiff.includes('+export') ||
94
+ lowerDiff.includes('+const') ||
95
+ lowerDiff.includes('+class');
96
+
97
+ const featKeywords = ['add', 'create', 'implement', 'feature', 'new'];
98
+ const hasFeatKeywords = featKeywords.some(k => lowerDiff.includes(k));
99
+
100
+ return hasNewFiles || (hasNewFunctions && hasFeatKeywords);
101
+ }
102
+ }
103
+
@@ -0,0 +1,29 @@
1
+ import type { CommitType } from '@/ai/commit-type-analyzer.ts';
2
+
3
+ export class EmojiMapper {
4
+ private emojiMap: Record<CommitType, string> = {
5
+ feat: '✨',
6
+ fix: '🐛',
7
+ refactor: '♻️',
8
+ chore: '🔧',
9
+ docs: '📝',
10
+ style: '💄',
11
+ test: '✅',
12
+ perf: '⚡',
13
+ };
14
+
15
+ getEmoji(type: CommitType): string {
16
+ return this.emojiMap[type];
17
+ }
18
+
19
+ replaceTypeWithEmoji(commitMessage: string): string {
20
+ for (const [type, emoji] of Object.entries(this.emojiMap)) {
21
+ const pattern = new RegExp(`^${type}`, 'i');
22
+ if (pattern.test(commitMessage)) {
23
+ return commitMessage.replace(pattern, emoji);
24
+ }
25
+ }
26
+ return commitMessage;
27
+ }
28
+ }
29
+
@@ -0,0 +1,185 @@
1
+ import { AIService } from '@/ai/ai.service.ts';
2
+ import type { CommitaConfig } from '@/config/config.types.ts';
3
+ import { FileGrouper } from '@/git/file-grouper.ts';
4
+ import type { FileChange } from '@/git/git.service.ts';
5
+ import { GitService } from '@/git/git.service.ts';
6
+ import { ProjectDetector } from '@/git/project-detector.ts';
7
+ import { PatternMatcher } from '@/utils/pattern-matcher.ts';
8
+ import chalk from 'chalk';
9
+
10
+ export interface CommitOptions {
11
+ all: boolean;
12
+ ignore: string;
13
+ push: boolean;
14
+ config?: string;
15
+ }
16
+
17
+ export class CommitHandler {
18
+ private gitService: GitService;
19
+ private fileGrouper: FileGrouper;
20
+ private aiService: AIService;
21
+ private config: CommitaConfig;
22
+
23
+ constructor(config: CommitaConfig) {
24
+ this.config = config;
25
+ this.gitService = new GitService();
26
+ this.fileGrouper = new FileGrouper();
27
+ this.aiService = new AIService(config);
28
+ }
29
+
30
+ async execute(options: CommitOptions): Promise<void> {
31
+ console.log(chalk.blue('🤖 Commita - AI-powered auto-commit\n'));
32
+
33
+ await this.gitService.init();
34
+
35
+ const boundaries = ProjectDetector.detect(this.gitService.getRootDir());
36
+ this.fileGrouper = new FileGrouper(boundaries);
37
+
38
+ const patternMatcher = new PatternMatcher(
39
+ PatternMatcher.parsePatterns(options.ignore)
40
+ );
41
+
42
+ try {
43
+ const stagedChanges = await this.gitService.getStagedChanges();
44
+
45
+ if (!options.all && stagedChanges.length === 0) {
46
+ console.error(chalk.red('❌ Error: No staged changes found.\n'));
47
+ console.log(chalk.yellow('Either stage some changes or use the --all flag to process all changes.\n'));
48
+ console.log(chalk.gray('Examples:'));
49
+ console.log(chalk.gray(' git add <files> # Stage specific files'));
50
+ console.log(chalk.gray(' commita --all # Process all changes\n'));
51
+ process.exit(1);
52
+ }
53
+
54
+ if (stagedChanges.length > 0 && !options.all) {
55
+ console.log(chalk.yellow('📦 Found staged changes. Grouping and processing them...\n'));
56
+ await this.processGroupedStagedChanges(stagedChanges);
57
+ } else if (stagedChanges.length > 0 && options.all) {
58
+ console.log(chalk.yellow('⚠️ Ignoring staged changes due to --all flag\n'));
59
+ }
60
+
61
+ if (options.all) {
62
+ await this.processAllChanges(patternMatcher);
63
+ }
64
+
65
+ if (options.push) {
66
+ await this.pushChanges();
67
+ }
68
+
69
+ console.log(chalk.green('\n✨ Done!\n'));
70
+ } catch (error) {
71
+ if (error instanceof Error) {
72
+ console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
73
+ } else {
74
+ console.error(chalk.red('\n❌ An unknown error occurred\n'));
75
+ }
76
+ process.exit(1);
77
+ }
78
+ }
79
+
80
+ private async _processChangesInGroups(changes: FileChange[], isStaged: boolean): Promise<void> {
81
+ if (changes.length === 0) {
82
+ console.log(chalk.yellow(`No ${isStaged ? 'staged' : 'unstaged'} changes found to group. Skipping...`));
83
+ return;
84
+ }
85
+
86
+ if (isStaged) {
87
+ const allFiles = changes.map(f => f.path);
88
+ await this.gitService.unstageFiles(allFiles);
89
+ }
90
+
91
+ const groups = this.fileGrouper.groupByPath(changes);
92
+ const optimizedGroups = this.fileGrouper.optimizeGroups(groups);
93
+
94
+ console.log(chalk.blue(`Found ${optimizedGroups.length} group(s) of ${isStaged ? 'staged' : 'unstaged'} changes:\n`));
95
+
96
+ for (const [index, group] of optimizedGroups.entries()) {
97
+ console.log(chalk.cyan(`\n[${index + 1}/${optimizedGroups.length}] Processing ${isStaged ? 'staged' : 'unstaged'}: ${group.scope}`));
98
+ console.log(chalk.gray(`Files: ${group.files.map(f => f.path).join(', ')}\n`));
99
+
100
+ const files = group.files.map(f => f.path);
101
+ const diff = await this.gitService.getDiff(files, false);
102
+
103
+ if (!diff) {
104
+ console.log(chalk.yellow(` No diff found for this ${isStaged ? 'staged' : 'unstaged'} group. Skipping...`));
105
+ continue;
106
+ }
107
+
108
+ console.log(chalk.cyan(' Generating commit message...'));
109
+ const message = await this.aiService.generateCommitMessage(diff, files, group.scope);
110
+
111
+ console.log(chalk.gray(' Commit message:'));
112
+ console.log(chalk.white(` ${message.replace(/\n/g, '\n ')}`));
113
+ console.log();
114
+
115
+ await this.gitService.stageFiles(files);
116
+ await this.gitService.commit(message);
117
+ console.log(chalk.green(` \u2713 Committed ${files.length} ${isStaged ? 'staged' : 'unstaged'} file(s)`));
118
+ }
119
+ }
120
+
121
+ private async processGroupedStagedChanges(stagedChanges: FileChange[]): Promise<void> {
122
+ await this._processChangesInGroups(stagedChanges, true);
123
+ }
124
+
125
+ private async processAllChanges(patternMatcher: PatternMatcher): Promise<void> {
126
+ const unstagedChanges = await this.gitService.getUnstagedChanges();
127
+
128
+ if (unstagedChanges.length === 0) {
129
+ console.log(chalk.yellow('No unstaged changes found.'));
130
+ return;
131
+ }
132
+
133
+ const filteredChanges = patternMatcher.filterFiles(unstagedChanges);
134
+
135
+ if (filteredChanges.length === 0) {
136
+ console.log(chalk.yellow('All files were filtered out by ignore patterns.'));
137
+ return;
138
+ }
139
+
140
+ await this._processChangesInGroups(filteredChanges, false);
141
+ }
142
+
143
+ private async pushChanges(): Promise<void> {
144
+ const hasRemote = await this.gitService.hasRemote();
145
+
146
+ if (!hasRemote) {
147
+ console.log(chalk.yellow('\n⚠️ No remote repository configured. Skipping push.'));
148
+ return;
149
+ }
150
+
151
+ console.log(chalk.blue('\n📤 Pushing changes...'));
152
+
153
+ try {
154
+ await this.gitService.push();
155
+ console.log(chalk.green('✓ Changes pushed successfully'));
156
+ } catch (error) {
157
+ console.log(chalk.yellow('⚠️ Failed to push changes. You may need to push manually.'));
158
+ if (error instanceof Error) {
159
+ console.log(chalk.gray(` Reason: ${error.message}`));
160
+ }
161
+ }
162
+ }
163
+
164
+ private extractScopeFromFiles(files: string[]): string {
165
+ if (files.length === 0) return 'root';
166
+
167
+ const firstFile = files[0];
168
+ if (!firstFile) return 'root';
169
+
170
+ const parts = firstFile.split('/');
171
+
172
+ if (parts.length === 1) return 'root';
173
+ if (parts.length === 2) return parts[0] || 'root';
174
+
175
+ const commonDirs = ['src', 'lib', 'app'];
176
+ const srcIndex = parts.findIndex(p => commonDirs.includes(p));
177
+
178
+ if (srcIndex !== -1 && srcIndex + 1 < parts.length) {
179
+ return parts[srcIndex + 1] || 'root';
180
+ }
181
+
182
+ return parts[0] || 'root';
183
+ }
184
+ }
185
+
@@ -0,0 +1,58 @@
1
+ import type { CommitOptions } from '@/cli/commit-handler.ts';
2
+ import { CommitHandler } from '@/cli/commit-handler.ts';
3
+ import type { SetOptions } from '@/cli/set-handler.ts';
4
+ import { SetHandler } from '@/cli/set-handler.ts';
5
+ import { ConfigLoader } from '@/config/config.loader.ts';
6
+ import chalk from 'chalk';
7
+ import { Command } from 'commander';
8
+ import packageJson from '../../package.json' with { type: 'json' };
9
+
10
+ export async function runCLI() {
11
+ const program = new Command();
12
+
13
+ program
14
+ .name('commita')
15
+ .description('AI-powered git auto-commit tool')
16
+ .version(packageJson.version, '-v, --version', 'Show version number')
17
+ .option('-a, --all', 'Process all changes grouped by folders', false)
18
+ .option('-i, --ignore <patterns>', 'Comma-separated patterns to exclude', '')
19
+ .option('--no-push', 'Skip pushing after commit')
20
+ .option('-c, --config <path>', 'Path to custom config file')
21
+ .action(async (options: CommitOptions) => {
22
+ try {
23
+ const configLoader = new ConfigLoader();
24
+ const config = await configLoader.load(options.config);
25
+
26
+ const handler = new CommitHandler(config);
27
+ await handler.execute(options);
28
+ } catch (error) {
29
+ if (error instanceof Error) {
30
+ console.error(chalk.red(`\n❌ Fatal error: ${error.message}\n`));
31
+ } else {
32
+ console.error(chalk.red('\n❌ An unknown fatal error occurred\n'));
33
+ }
34
+ process.exit(1);
35
+ }
36
+ });
37
+
38
+ program
39
+ .command('set <key-value>')
40
+ .description('Set configuration value (format: KEY=value or KEY to prompt)')
41
+ .option('-l, --local', 'Set in project .commita file instead of global ~/.commita')
42
+ .action(async (keyValue: string, options: SetOptions) => {
43
+ try {
44
+ const handler = new SetHandler();
45
+ await handler.execute(keyValue, options);
46
+ } catch (error) {
47
+ if (error instanceof Error) {
48
+ console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
49
+ } else {
50
+ console.error(chalk.red('\n❌ An unknown error occurred\n'));
51
+ }
52
+ process.exit(1);
53
+ }
54
+ });
55
+
56
+ await program.parseAsync(process.argv);
57
+ }
58
+
@@ -0,0 +1,134 @@
1
+ import { ConfigWriter } from '@/config/config.writer.ts';
2
+ import chalk from 'chalk';
3
+ import { createInterface } from 'readline';
4
+ import { homedir } from 'os';
5
+ import { join } from 'path';
6
+
7
+ export interface SetOptions {
8
+ local?: boolean;
9
+ }
10
+
11
+ const SENSITIVE_KEYS = ['OPENAI_API_KEY', 'GEMINI_API_KEY'];
12
+
13
+ export class SetHandler {
14
+ private configWriter: ConfigWriter;
15
+
16
+ constructor() {
17
+ this.configWriter = new ConfigWriter();
18
+ }
19
+
20
+ async execute(keyValue: string, options: SetOptions): Promise<void> {
21
+ const { key, value } = this.parseKeyValue(keyValue);
22
+
23
+ let finalValue = value;
24
+ if (!finalValue) {
25
+ finalValue = await this.promptForValue(key);
26
+ }
27
+
28
+ const configPath = this.getConfigPath(options.local || false);
29
+ await this.configWriter.set(key, finalValue, configPath);
30
+
31
+ console.log(chalk.green('\n✓ Configuration updated successfully'));
32
+ console.log(` ${chalk.cyan(key)}=${chalk.green(this.maskSensitiveValue(key, finalValue))} set in ${chalk.gray(configPath)}\n`);
33
+ }
34
+
35
+ private parseKeyValue(keyValue: string): { key: string; value?: string } {
36
+ const eqIndex = keyValue.indexOf('=');
37
+
38
+ if (eqIndex === -1) {
39
+ // No value provided, will prompt interactively
40
+ return {
41
+ key: keyValue.trim(),
42
+ value: undefined,
43
+ };
44
+ }
45
+
46
+ const key = keyValue.substring(0, eqIndex).trim();
47
+ const value = keyValue.substring(eqIndex + 1).trim();
48
+
49
+ if (!key) {
50
+ throw new Error('Key cannot be empty');
51
+ }
52
+
53
+ return { key, value };
54
+ }
55
+
56
+ private getConfigPath(isLocal: boolean): string {
57
+ if (isLocal) {
58
+ return join(process.cwd(), '.commita');
59
+ }
60
+ return join(homedir(), '.commita');
61
+ }
62
+
63
+ private async promptForValue(key: string): Promise<string> {
64
+ if (!process.stdin.isTTY) {
65
+ throw new Error(`Value required for key '${key}'. Use format: commita set KEY=value`);
66
+ }
67
+
68
+ const isSensitive = this.isSensitiveKey(key);
69
+ const prompt = `Enter value for ${chalk.cyan(key)}${isSensitive ? ' (hidden)' : ''}: `;
70
+
71
+ return new Promise((resolve, reject) => {
72
+ const readline = createInterface({
73
+ input: process.stdin,
74
+ output: isSensitive ? process.stderr : process.stdout,
75
+ terminal: true,
76
+ });
77
+
78
+ if (isSensitive) {
79
+ // Hide input for sensitive keys
80
+ process.stdin.setRawMode?.(true);
81
+ process.stderr.write(prompt);
82
+ } else {
83
+ readline.question(prompt, () => {});
84
+ }
85
+
86
+ let value = '';
87
+
88
+ process.stdin.on('data', (buffer) => {
89
+ const char = buffer.toString();
90
+
91
+ if (char === '\n' || char === '\r') {
92
+ if (isSensitive) {
93
+ process.stdin.setRawMode?.(false);
94
+ process.stderr.write('\n');
95
+ }
96
+ readline.close();
97
+ resolve(value);
98
+ } else if (char === '\u0003') {
99
+ // Handle Ctrl+C
100
+ if (isSensitive) {
101
+ process.stdin.setRawMode?.(false);
102
+ }
103
+ readline.close();
104
+ reject(new Error('Input cancelled'));
105
+ } else if (char === '\u007f') {
106
+ // Handle backspace
107
+ value = value.slice(0, -1);
108
+ } else {
109
+ value += char;
110
+ }
111
+ });
112
+
113
+ readline.once('close', () => {
114
+ if (!value) {
115
+ reject(new Error('No value provided'));
116
+ }
117
+ });
118
+ });
119
+ }
120
+
121
+ private isSensitiveKey(key: string): boolean {
122
+ return SENSITIVE_KEYS.includes(key.toUpperCase());
123
+ }
124
+
125
+ private maskSensitiveValue(key: string, value: string): string {
126
+ if (!this.isSensitiveKey(key)) {
127
+ return value;
128
+ }
129
+ if (value.length <= 4) {
130
+ return '*'.repeat(value.length);
131
+ }
132
+ return value.substring(0, 2) + '*'.repeat(value.length - 4) + value.substring(value.length - 2);
133
+ }
134
+ }