diff --git a/git-ai/src/commands/InitCommand.ts b/git-ai/src/commands/InitCommand.ts index 655831b..23398e1 100644 --- a/git-ai/src/commands/InitCommand.ts +++ b/git-ai/src/commands/InitCommand.ts @@ -12,7 +12,6 @@ import { logger } from '../utils/logger.js'; async function readSecretInput(rl: readline.Interface, prompt: string): Promise { const rlAny = rl as any; const originalWrite = rlAny._writeToOutput; - // Suppress character echoing: only allow the initial prompt to be written let promptWritten = false; rlAny._writeToOutput = function _writeToOutput(str: string) { if (!promptWritten) { @@ -29,6 +28,28 @@ async function readSecretInput(rl: readline.Interface, prompt: string): Promise< } } +/** + * Atomically writes content to filePath using a temp file + fsync + rename, + * ensuring the file has permissions 0o600. + */ +function atomicWriteFileSync(filePath: string, content: string): void { + const dir = path.dirname(filePath); + const tempPath = path.join(dir, `.tmp-${process.pid}-${Date.now()}`); + const fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); + try { + try { + fs.writeFileSync(fd, content); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tempPath, filePath); + } catch (error) { + try { fs.unlinkSync(tempPath); } catch { /* best-effort cleanup */ } + throw error; + } +} + export async function initCommand() { const rl = readline.createInterface({ input: process.stdin, @@ -38,6 +59,7 @@ export async function initCommand() { console.log('šŸš€ Welcome to AI-Git-Terminal Setup\n'); try { + // --- Step 1: Read API Key --- let apiKey = ''; while (!apiKey) { const apiKeyInput = await readSecretInput(rl, 'šŸ”‘ Enter your Gemini API Key: '); @@ -47,56 +69,65 @@ export async function initCommand() { } } + // --- Step 2: Read model name --- const modelInput = await rl.question('šŸ¤– Enter model name (default: gemini-1.5-flash): '); const model = modelInput.trim() || 'gemini-1.5-flash'; + // --- Step 3: Build config object --- const newConfig: Config = { - ai: { - provider: 'gemini', - apiKey, - model: model, - }, - git: { - autoStage: false, - }, - ui: { - theme: 'dark', - showIcons: true, - }, + ai: { provider: 'gemini', apiKey, model }, + git: { autoStage: false }, + ui: { theme: 'dark', showIcons: true }, }; - // Validate with Zod and persist with restricted permissions (mode 0o600) + // Validate with Zod ConfigSchema.parse(newConfig); const configPath = path.join(os.homedir(), '.aigitrc'); - if (fs.existsSync(configPath)) { - const overwriteChoice = (await rl.question( - 'āš ļø Existing config found. Choose [o]verwrite, [b]ackup then replace, or [c]ancel: ' - )).trim().toLowerCase(); - - if (overwriteChoice === 'b' || overwriteChoice === 'backup') { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const backupPath = `${configPath}.bak-${timestamp}`; - fs.renameSync(configPath, backupPath); - console.log(`šŸ“¦ Existing config backed up to ${backupPath}`); - } else if (overwriteChoice === 'o' || overwriteChoice === 'overwrite') { - console.log('šŸ“ Overwriting existing config file.'); - } else { - console.log('🚫 Initialization canceled. Existing config left unchanged.'); - return; + // --- Step 4: Attempt atomic creation --- + try { + const fd = fs.openSync(configPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_RDWR, 0o600); + try { + fs.writeFileSync(fd, JSON.stringify(newConfig, null, 2)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); } + console.log(`\nāœ… Configuration saved to ${configPath}`); + console.log('Try running: ai-git commit'); + return; + } catch (err: any) { + if (err.code !== 'EEXIST') throw err; + // File already exists, proceed to backup/overwrite prompt } - fs.writeFileSync(configPath, JSON.stringify(newConfig, null, 2), { mode: 0o600 }); - fs.chmodSync(configPath, 0o600); + // --- Step 5: Handle existing file --- + const overwriteChoice = (await rl.question( + 'āš ļø Existing config found. Choose [o]verwrite, [b]ackup then replace, or [c]ancel: ' + )).trim().toLowerCase(); + + if (overwriteChoice === 'b' || overwriteChoice === 'backup') { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupPath = `${configPath}.bak-${timestamp}`; + fs.renameSync(configPath, backupPath); + console.log(`šŸ“¦ Existing config backed up to ${backupPath}`); + atomicWriteFileSync(configPath, JSON.stringify(newConfig, null, 2)); + } else if (overwriteChoice === 'o' || overwriteChoice === 'overwrite') { + atomicWriteFileSync(configPath, JSON.stringify(newConfig, null, 2)); + console.log('šŸ“ Overwriting existing config file.'); + } else { + console.log('🚫 Initialization canceled. Existing config left unchanged.'); + return; + } console.log(`\nāœ… Configuration saved to ${configPath}`); console.log('Try running: ai-git commit'); + } catch (error) { logger.error('Failed to save configuration: ' + (error instanceof Error ? error.message : String(error))); console.error('\nāŒ Invalid input or failed to write config file.'); } finally { rl.close(); } -} \ No newline at end of file +} diff --git a/git-ai/src/services/ConfigService.ts b/git-ai/src/services/ConfigService.ts index 16d7453..a2de320 100644 --- a/git-ai/src/services/ConfigService.ts +++ b/git-ai/src/services/ConfigService.ts @@ -55,7 +55,21 @@ export class ConfigService { public saveConfig(newConfig: Config): void { const validated = ConfigSchema.parse(newConfig); - fs.writeFileSync(ConfigService.CONFIG_PATH, JSON.stringify(validated, null, 2), { mode: 0o600 }); + const configPath = ConfigService.CONFIG_PATH; + const tempPath = path.join(path.dirname(configPath), `.tmp-${process.pid}-${Date.now()}`); + const fd = fs.openSync(tempPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600); + try { + try { + fs.writeFileSync(fd, JSON.stringify(validated, null, 2)); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tempPath, configPath); + } catch (error) { + try { fs.unlinkSync(tempPath); } catch { /* best-effort cleanup */ } + throw error; + } this.config = validated; } } \ No newline at end of file