Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://www.facebook.com/mrd.900s>",
"icon": "resources/icons/icon.png",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
265 changes: 256 additions & 9 deletions src/services/ConfigService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand All @@ -40,8 +44,11 @@ export class ConfigService {
*/
async isConfigured(): Promise<boolean> {
const config = this.getConfig();
if (!config.repositoryUrl) {
return false;
}
const pat = await this.getCredentials();
return !!(config.repositoryUrl && pat);
return !!pat;
}

/**
Expand All @@ -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<void> {
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<string | undefined> {
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<void> {
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<void> {
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<void>((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<string | undefined> {
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<void> {
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<void>((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<void> {
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;
}

/**
Expand Down
Loading
Loading