feat(git): add token authentication and credential discovery

Add support for token-based git authentication in identity profiles.
Enable discovery and import of credentials from ~/.git-credentials file.
Introduce remote URL-based filtering for token identities in git view.
This commit is contained in:
btriapitsyn
2026-01-14 02:47:26 +02:00
parent d56eef8aa6
commit fa83c1e645
16 changed files with 704 additions and 44 deletions
+28
View File
@@ -3305,6 +3305,17 @@ async function main(options = {}) {
}
});
app.get('/api/git/discover-credentials', async (req, res) => {
try {
const { discoverGitCredentials } = await import('./lib/git-credentials.js');
const credentials = discoverGitCredentials();
res.json(credentials);
} catch (error) {
console.error('Failed to discover git credentials:', error);
res.status(500).json({ error: 'Failed to discover git credentials' });
}
});
app.get('/api/git/check', async (req, res) => {
const { isGitRepository } = await getGitLibraries();
try {
@@ -3321,6 +3332,23 @@ async function main(options = {}) {
}
});
app.get('/api/git/remote-url', async (req, res) => {
const { getRemoteUrl } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const remote = req.query.remote || 'origin';
const url = await getRemoteUrl(directory, remote);
res.json({ url });
} catch (error) {
console.error('Failed to get remote url:', error);
res.status(500).json({ error: 'Failed to get remote url' });
}
});
app.get('/api/git/current-identity', async (req, res) => {
const { getCurrentIdentity } = await getGitLibraries();
try {
@@ -0,0 +1,87 @@
import fs from 'fs';
import path from 'path';
import os from 'os';
const GIT_CREDENTIALS_PATH = path.join(os.homedir(), '.git-credentials');
/**
* Parse ~/.git-credentials file and return discovered credentials.
* Format: https://username:token@host or https://username:token@host/path
* @returns {Array<{host: string, username: string}>}
*/
export function discoverGitCredentials() {
const credentials = [];
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return credentials;
}
try {
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
// Include path for repo-specific tokens (e.g., github.com/user/repo)
const host = hostname + pathname;
const username = url.username || '';
if (host && username) {
// Avoid duplicates
const exists = credentials.some(c => c.host === host && c.username === username);
if (!exists) {
credentials.push({ host, username });
}
}
} catch {
// Skip malformed lines
continue;
}
}
} catch (error) {
console.error('Failed to read .git-credentials:', error);
}
return credentials;
}
/**
* Get credential for a specific host from ~/.git-credentials
* @param {string} host - The host to look up (e.g., "github.com" or "github.com/user/repo")
* @returns {{username: string, token: string} | null}
*/
export function getCredentialForHost(host) {
if (!fs.existsSync(GIT_CREDENTIALS_PATH)) {
return null;
}
try {
const content = fs.readFileSync(GIT_CREDENTIALS_PATH, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
for (const line of lines) {
try {
const url = new URL(line.trim());
const hostname = url.hostname;
const pathname = url.pathname && url.pathname !== '/' ? url.pathname : '';
const credHost = hostname + pathname;
if (credHost === host) {
return {
username: url.username || '',
token: url.password || ''
};
}
} catch {
continue;
}
}
} catch (error) {
console.error('Failed to read .git-credentials for host lookup:', error);
}
return null;
}
@@ -66,7 +66,9 @@ export function createProfile(profileData) {
name: profileData.name || profileData.userName,
userName: profileData.userName,
userEmail: profileData.userEmail,
authType: profileData.authType || 'ssh',
sshKey: profileData.sshKey || null,
host: profileData.host || null,
color: profileData.color || 'keyword',
icon: profileData.icon || 'branch'
};
+27 -1
View File
@@ -117,6 +117,17 @@ export async function getGlobalIdentity() {
}
}
export async function getRemoteUrl(directory, remoteName = 'origin') {
const git = simpleGit(normalizeDirectoryPath(directory));
try {
const url = await git.remote(['get-url', remoteName]);
return url?.trim() || null;
} catch {
return null;
}
}
export async function getCurrentIdentity(directory) {
const git = simpleGit(normalizeDirectoryPath(directory));
@@ -157,13 +168,28 @@ export async function setLocalIdentity(directory, profile) {
await git.addConfig('user.name', profile.userName, false, 'local');
await git.addConfig('user.email', profile.userEmail, false, 'local');
if (profile.sshKey) {
const authType = profile.authType || 'ssh';
if (authType === 'ssh' && profile.sshKey) {
await git.addConfig(
'core.sshCommand',
`ssh -i ${profile.sshKey}`,
false,
'local'
);
// Clear credential helper if previously set for token auth
await git.raw(['config', '--local', '--unset', 'credential.helper']).catch(() => {});
} else if (authType === 'token' && profile.host) {
// For token auth, configure git to use the store credential helper
// which reads from ~/.git-credentials
await git.addConfig(
'credential.helper',
'store',
false,
'local'
);
// Clear SSH command if previously set
await git.raw(['config', '--local', '--unset', 'core.sshCommand']).catch(() => {});
}
return true;