diff --git a/CHANGELOG.md b/CHANGELOG.md index a66597f..639d956 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-01-15 + +### Added +- Git Credential Manager integration for persistent credential storage +- Per-repository credential support (no conflicts with multiple GitHub accounts) +- Cross-platform credential storage (macOS Keychain, Windows Credential Manager, Linux libsecret/GNOME Keyring) +- Automatic credential helper configuration + +### Changed +- Credentials now stored via Git credential manager instead of VS Code secret storage +- Credentials persist across all workspaces and VS Code installations +- No need to re-enter credentials when switching workspaces + +### Security +- Credentials stored in OS-native secure storage +- Per-repository isolation prevents credential conflicts +- Backwards compatible with existing host-level credentials + ## [0.1.0] - 2026-01-13 ### Added diff --git a/package.json b/package.json index 461c0c2..a9ed381 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "antigravity-sync", "displayName": "Antigravity Sync", "description": "Sync your Gemini Antigravity context across machines via private Git repository. Never lose your AI conversation history, Knowledge Items, and brain artifacts when switching computers.", - "version": "0.1.9", + "version": "0.2.0", "publisher": "mrd9999", "author": "Dung Le ", "icon": "resources/icons/icon.png", @@ -137,7 +137,7 @@ } }, "scripts": { - "vscode:prepublish": "yarn run build", + "vscode:prepublish": "npm run build", "build": "webpack --mode production", "build:dev": "webpack --mode development", "watch": "webpack --mode development --watch", @@ -147,8 +147,8 @@ "test:watch": "jest --watch", "test:coverage": "jest --coverage", "test:e2e": "vscode-test", - "package": "vsce package --yarn", - "publish": "vsce publish --yarn" + "package": "vsce package --no-yarn", + "publish": "vsce publish --no-yarn" }, "devDependencies": { "@types/jest": "^29.5.11", diff --git a/src/extension.ts b/src/extension.ts index 0b6f9b6..7471638 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -178,8 +178,9 @@ async function configureRepository( async (progress) => { progress.report({ message: 'Checking repository...' }); - await configService.saveCredentials(token); + // URL must be set first (credentials storage depends on URL) await configService.setRepositoryUrl(repoUrl); + await configService.saveCredentials(token); progress.report({ message: 'Initializing sync...' }); await syncService.initialize(); diff --git a/src/services/ConfigService.ts b/src/services/ConfigService.ts index ede16c5..762ef8b 100644 --- a/src/services/ConfigService.ts +++ b/src/services/ConfigService.ts @@ -4,6 +4,11 @@ import * as vscode from 'vscode'; import * as os from 'os'; import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as fs from 'fs'; + +const execAsync = promisify(exec); export interface SyncConfig { repositoryUrl: string; @@ -14,7 +19,6 @@ export interface SyncConfig { } export class ConfigService { - private static readonly SECRETS_KEY = 'antigravitySync.gitToken'; private readonly context: vscode.ExtensionContext; constructor(context: vscode.ExtensionContext) { @@ -40,8 +44,11 @@ export class ConfigService { */ async isConfigured(): Promise { const config = this.getConfig(); + if (!config.repositoryUrl) { + return false; + } const pat = await this.getCredentials(); - return !!(config.repositoryUrl && pat); + return !!pat; } /** @@ -58,26 +65,266 @@ export class ConfigService { return path.join(os.homedir(), '.gemini-sync-repo'); } - /** - * Save Git access token securely + * Save Git access token using Git credential manager + * This stores credentials in the system's secure credential store */ async saveCredentials(token: string): Promise { - await this.context.secrets.store(ConfigService.SECRETS_KEY, token); + const config = this.getConfig(); + if (!config.repositoryUrl) { + throw new Error('Repository URL must be set before saving credentials'); + } + await this.storeGitCredentials(config.repositoryUrl, token); } /** - * Get Git access token + * Get Git access token from Git credential manager */ async getCredentials(): Promise { - return await this.context.secrets.get(ConfigService.SECRETS_KEY); + const config = this.getConfig(); + if (!config.repositoryUrl) { + return undefined; + } + return await this.getGitCredentials(config.repositoryUrl); } /** - * Delete credentials + * Delete credentials from Git credential manager */ async deleteCredentials(): Promise { - await this.context.secrets.delete(ConfigService.SECRETS_KEY); + const config = this.getConfig(); + if (config.repositoryUrl) { + await this.deleteGitCredentials(config.repositoryUrl); + } + } + + /** + * Store credentials in Git credential manager (per-repository) + */ + private async storeGitCredentials(url: string, token: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + throw new Error('Invalid Git URL'); + } + + // Configure credential helper first + await this.configureCredentialHelper(); + + const isGitLab = url.includes('gitlab'); + const username = isGitLab ? 'oauth2' : 'token'; + + // Include path for per-repository credential storage + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\npath=${parsed.path}\nusername=${username}\npassword=${token}\n`; + + try { + await new Promise((resolve, reject) => { + const child = exec('git credential approve', (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + child.stdin?.write(credentialInput); + child.stdin?.end(); + }); + } catch { + // Fallback: write to .git-credentials file directly (with full path for per-repo) + const credentialStorePath = path.join(os.homedir(), '.git-credentials'); + // Store with full path: https://token:TOKEN@github.com/owner/repo.git + const credentialLine = `${parsed.protocol}://${username}:${token}@${parsed.host}${parsed.path}\n`; + + let existingContent = ''; + if (fs.existsSync(credentialStorePath)) { + existingContent = fs.readFileSync(credentialStorePath, 'utf8'); + // Remove existing credential for this exact repo (not just host) + const repoIdentifier = `@${parsed.host}${parsed.path}`; + const lines = existingContent.split('\n').filter(line => !line.includes(repoIdentifier)); + existingContent = lines.join('\n'); + if (existingContent && !existingContent.endsWith('\n')) { + existingContent += '\n'; + } + } + + fs.writeFileSync(credentialStorePath, existingContent + credentialLine, { mode: 0o600 }); + } + } + + /** + * Retrieve credentials from Git credential manager (per-repository) + */ + private async getGitCredentials(url: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + return undefined; + } + + // Method 1: Try using git credential fill with execSync (with path for per-repo) + try { + const { execSync } = require('child_process'); + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\npath=${parsed.path}\n`; + const result = execSync('git credential fill', { + input: credentialInput, + encoding: 'utf8', + timeout: 5000 + }); + + const passwordMatch = result.match(/password=(.+)/); + if (passwordMatch) { + return passwordMatch[1].trim(); + } + } catch { + // git credential fill failed, try fallback + } + + // Method 2: Read directly from .git-credentials file + const credentialStorePath = path.join(os.homedir(), '.git-credentials'); + if (fs.existsSync(credentialStorePath)) { + const content = fs.readFileSync(credentialStorePath, 'utf8'); + const repoIdentifier = `@${parsed.host}${parsed.path}`; + + // First, try to find exact repo match (per-repository credential) + for (const line of content.split('\n')) { + if (line.includes(repoIdentifier)) { + const urlMatch = line.match(/\/\/([^:]+):([^@]+)@/); + if (urlMatch) { + return urlMatch[2]; + } + } + } + + // Fallback: try host-only match (for backwards compatibility) + for (const line of content.split('\n')) { + if (line.includes(`@${parsed.host}`) && !line.includes(`@${parsed.host}/`)) { + const urlMatch = line.match(/\/\/([^:]+):([^@]+)@/); + if (urlMatch) { + return urlMatch[2]; + } + } + } + + // Last resort: any credential for this host + for (const line of content.split('\n')) { + if (line.includes(`@${parsed.host}`)) { + const urlMatch = line.match(/\/\/([^:]+):([^@]+)@/); + if (urlMatch) { + return urlMatch[2]; + } + } + } + } + + return undefined; + } + + /** + * Delete credentials from Git credential manager (per-repository) + */ + private async deleteGitCredentials(url: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + return; + } + + // Include path for per-repository credential deletion + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\npath=${parsed.path}\n`; + + try { + await new Promise((resolve, reject) => { + const child = exec('git credential reject', (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + child.stdin?.write(credentialInput); + child.stdin?.end(); + }); + } catch { + // Fallback: remove from .git-credentials file (only this specific repo) + const credentialStorePath = path.join(os.homedir(), '.git-credentials'); + if (fs.existsSync(credentialStorePath)) { + const content = fs.readFileSync(credentialStorePath, 'utf8'); + const repoIdentifier = `@${parsed.host}${parsed.path}`; + const lines = content.split('\n').filter(line => !line.includes(repoIdentifier)); + fs.writeFileSync(credentialStorePath, lines.join('\n'), { mode: 0o600 }); + } + } + } + + /** + * Configure Git credential helper to use system store + */ + private async configureCredentialHelper(): Promise { + try { + const { stdout } = await execAsync('git config --global credential.helper'); + if (stdout.trim()) { + return; // Already configured + } + } catch { + // Not configured + } + + const platform = process.platform; + let helper: string; + + if (platform === 'darwin') { + helper = 'osxkeychain'; + } else if (platform === 'win32') { + helper = 'manager'; + } else { + // Linux - prefer libsecret (GNOME Keyring), fall back to store + try { + await execAsync('which git-credential-libsecret'); + helper = 'libsecret'; + } catch { + helper = 'store'; + } + } + + await execAsync(`git config --global credential.helper ${helper}`); + } + + /** + * Parse Git URL to extract protocol, host, and path (for per-repository credentials) + */ + private parseGitUrl(url: string): { protocol: string; host: string; path: string } | null { + // Handle https://host/owner/repo.git or https://host/owner/repo + if (url.startsWith('https://')) { + const match = url.match(/https:\/\/([^/]+)(\/.*)?/); + if (match) { + let repoPath = match[2] || ''; + // Normalize path: ensure it starts with / and ends with .git + if (repoPath && !repoPath.endsWith('.git')) { + repoPath = repoPath.replace(/\/$/, '') + '.git'; + } + return { protocol: 'https', host: match[1], path: repoPath }; + } + } + // Handle http://host/owner/repo + if (url.startsWith('http://')) { + const match = url.match(/http:\/\/([^/]+)(\/.*)?/); + if (match) { + let repoPath = match[2] || ''; + if (repoPath && !repoPath.endsWith('.git')) { + repoPath = repoPath.replace(/\/$/, '') + '.git'; + } + return { protocol: 'http', host: match[1], path: repoPath }; + } + } + // Handle git@host:owner/repo.git + if (url.startsWith('git@')) { + const match = url.match(/git@([^:]+):(.+)/); + if (match) { + let repoPath = '/' + match[2]; + if (!repoPath.endsWith('.git')) { + repoPath = repoPath.replace(/\/$/, '') + '.git'; + } + return { protocol: 'https', host: match[1], path: repoPath }; + } + } + return null; } /** diff --git a/src/services/GitService.ts b/src/services/GitService.ts index 8dd7705..a647af9 100644 --- a/src/services/GitService.ts +++ b/src/services/GitService.ts @@ -4,6 +4,10 @@ import simpleGit, { SimpleGit, SimpleGitOptions } from 'simple-git'; import * as fs from 'fs'; import * as path from 'path'; +import { exec } from 'child_process'; +import { promisify } from 'util'; + +const execAsync = promisify(exec); export type LogType = 'info' | 'success' | 'error'; export type LoggerCallback = (message: string, type: LogType) => void; @@ -51,6 +55,211 @@ export class GitService { } } + /** + * Store credentials in Git credential manager + * This stores credentials in the system's secure credential store + */ + async storeCredentials(url: string, token: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + throw new Error('Invalid Git URL'); + } + + // Format for git credential store: + // protocol=https + // host=github.com + // username=token (or oauth2 for GitLab) + // password= + const isGitLab = url.includes('gitlab'); + const username = isGitLab ? 'oauth2' : 'token'; + + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\nusername=${username}\npassword=${token}\n`; + + try { + // First, configure credential helper if not set + await this.configureCredentialHelper(); + + // Store the credential using git credential approve + await new Promise((resolve, reject) => { + const child = exec('git credential approve', { cwd: this.repoPath }, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + child.stdin?.write(credentialInput); + child.stdin?.end(); + }); + } catch (error) { + // Fallback: try git credential-store directly + const credentialStorePath = path.join(require('os').homedir(), '.git-credentials'); + const credentialLine = `${parsed.protocol}://${username}:${token}@${parsed.host}\n`; + + // Read existing credentials and check if this host already exists + let existingContent = ''; + if (fs.existsSync(credentialStorePath)) { + existingContent = fs.readFileSync(credentialStorePath, 'utf8'); + // Remove any existing credential for this host + const lines = existingContent.split('\n').filter(line => !line.includes(`@${parsed.host}`)); + existingContent = lines.join('\n'); + if (existingContent && !existingContent.endsWith('\n')) { + existingContent += '\n'; + } + } + + fs.writeFileSync(credentialStorePath, existingContent + credentialLine, { mode: 0o600 }); + } + } + + /** + * Retrieve credentials from Git credential manager + */ + async getCredentials(url: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + return undefined; + } + + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\n`; + + try { + const result = await new Promise((resolve, reject) => { + let output = ''; + const child = exec('git credential fill', { cwd: this.repoPath }, (error, stdout) => { + if (error) { + reject(error); + } else { + resolve(stdout); + } + }); + child.stdin?.write(credentialInput); + child.stdin?.end(); + }); + + // Parse the output to extract password + const passwordMatch = result.match(/password=(.+)/); + if (passwordMatch) { + return passwordMatch[1].trim(); + } + } catch { + // Fallback: try reading from .git-credentials directly + const credentialStorePath = path.join(require('os').homedir(), '.git-credentials'); + if (fs.existsSync(credentialStorePath)) { + const content = fs.readFileSync(credentialStorePath, 'utf8'); + const lines = content.split('\n'); + for (const line of lines) { + if (line.includes(`@${parsed.host}`)) { + // Extract password from URL format: protocol://username:password@host + const match = line.match(/:([^:@]+)@/); + if (match) { + return match[1]; + } + } + } + } + } + + return undefined; + } + + /** + * Delete credentials from Git credential manager + */ + async deleteCredentials(url: string): Promise { + const parsed = this.parseGitUrl(url); + if (!parsed) { + return; + } + + const credentialInput = `protocol=${parsed.protocol}\nhost=${parsed.host}\n`; + + try { + await new Promise((resolve, reject) => { + const child = exec('git credential reject', { cwd: this.repoPath }, (error) => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + child.stdin?.write(credentialInput); + child.stdin?.end(); + }); + } catch { + // Fallback: remove from .git-credentials file + const credentialStorePath = path.join(require('os').homedir(), '.git-credentials'); + if (fs.existsSync(credentialStorePath)) { + const content = fs.readFileSync(credentialStorePath, 'utf8'); + const lines = content.split('\n').filter(line => !line.includes(`@${parsed.host}`)); + fs.writeFileSync(credentialStorePath, lines.join('\n'), { mode: 0o600 }); + } + } + } + + /** + * Configure Git credential helper to use system store + */ + private async configureCredentialHelper(): Promise { + try { + // Check if credential helper is already configured globally + const { stdout } = await execAsync('git config --global credential.helper'); + if (stdout.trim()) { + return; // Already configured + } + } catch { + // Not configured, set it up + } + + // Configure credential helper based on platform + const platform = process.platform; + let helper: string; + + if (platform === 'darwin') { + helper = 'osxkeychain'; + } else if (platform === 'win32') { + helper = 'manager'; + } else { + // Linux - use store (file-based) or libsecret if available + try { + await execAsync('which git-credential-libsecret'); + helper = 'libsecret'; + } catch { + helper = 'store'; + } + } + + await execAsync(`git config --global credential.helper ${helper}`); + } + + /** + * Parse Git URL to extract protocol and host + */ + private parseGitUrl(url: string): { protocol: string; host: string; path: string } | null { + // Handle https://host/path format + if (url.startsWith('https://')) { + const match = url.match(/https:\/\/([^/]+)(\/.*)?/); + if (match) { + return { protocol: 'https', host: match[1], path: match[2] || '' }; + } + } + // Handle http://host/path format + if (url.startsWith('http://')) { + const match = url.match(/http:\/\/([^/]+)(\/.*)?/); + if (match) { + return { protocol: 'http', host: match[1], path: match[2] || '' }; + } + } + // Handle git@host:path format + if (url.startsWith('git@')) { + const match = url.match(/git@([^:]+):(.+)/); + if (match) { + return { protocol: 'https', host: match[1], path: '/' + match[2] }; + } + } + return null; + } + /** * Initialize or clone the repository */ diff --git a/src/ui/SidePanelProvider.ts b/src/ui/SidePanelProvider.ts index 178fe82..a46aa13 100644 --- a/src/ui/SidePanelProvider.ts +++ b/src/ui/SidePanelProvider.ts @@ -159,10 +159,11 @@ export class SidePanelProvider implements vscode.WebviewViewProvider { const tempGitService = new GitService(this._configService.getSyncRepoPath()); await tempGitService.verifyAccess(repoUrl, pat); - // Only save credentials after verification succeeds - this.sendLog('Saving credentials...', 'info'); - await this._configService.saveCredentials(pat); + // Save URL first (credentials storage depends on URL) + this.sendLog('Saving credentials to Git credential manager...', 'info'); await this._configService.setRepositoryUrl(repoUrl); + // Now save credentials (uses Git credential manager - persists across workspaces) + await this._configService.saveCredentials(pat); // Initialize sync this.sendLog('Initializing Git repository...', 'info');