Skip to content

Commit a736e98

Browse files
Add multiple git credentials support with migration
1 parent 15643ae commit a736e98

19 files changed

Lines changed: 1196 additions & 1089 deletions

File tree

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ RUN apt-get update && apt-get install -y \
2121
python3-venv \
2222
&& rm -rf /var/lib/apt/lists/*
2323

24+
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
25+
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
26+
&& apt-get update && apt-get install -y gh \
27+
&& rm -rf /var/lib/apt/lists/*
28+
2429
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
2530

2631
RUN curl -fsSL https://bun.sh/install | bash && \

backend/src/db/migrations.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,55 @@ export function runMigrations(db: Database): void {
121121
logger.error('Failed to migrate local_path format:', error)
122122
}
123123

124+
migrateGitTokenToCredentials(db)
125+
124126
logger.info('Database migrations completed successfully')
125127
} catch (error) {
126128
logger.error('Failed to run database migrations:', error)
127129
throw error
128130
}
129131
}
132+
133+
function migrateGitTokenToCredentials(db: Database): void {
134+
try {
135+
const rows = db.prepare('SELECT user_id, preferences FROM user_preferences').all() as Array<{
136+
user_id: string
137+
preferences: string
138+
}>
139+
140+
for (const row of rows) {
141+
try {
142+
const parsed = JSON.parse(row.preferences) as Record<string, unknown>
143+
const gitToken = parsed.gitToken as string | undefined
144+
const existingCredentials = parsed.gitCredentials as Array<unknown> | undefined
145+
146+
if (!gitToken) {
147+
continue
148+
}
149+
150+
if (existingCredentials && existingCredentials.length > 0) {
151+
continue
152+
}
153+
154+
const { gitToken: _, ...rest } = parsed
155+
const migrated = {
156+
...rest,
157+
gitCredentials: [{
158+
name: 'GitHub',
159+
host: 'https://github.com/',
160+
token: gitToken,
161+
}],
162+
}
163+
164+
db.prepare('UPDATE user_preferences SET preferences = ? WHERE user_id = ?')
165+
.run(JSON.stringify(migrated), row.user_id)
166+
167+
logger.info(`Migrated gitToken to gitCredentials for user: ${row.user_id}`)
168+
} catch (parseError) {
169+
logger.error(`Failed to parse preferences for user ${row.user_id}:`, parseError)
170+
}
171+
}
172+
} catch (error) {
173+
logger.error('Failed to migrate gitToken to gitCredentials:', error)
174+
}
175+
}

backend/src/routes/repos.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@ import path from 'path'
1414

1515
export function createRepoRoutes(database: Database) {
1616
const app = new Hono()
17-
17+
1818
app.post('/', async (c) => {
1919
try {
2020
const body = await c.req.json()
21-
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree } = body
22-
21+
const { repoUrl, localPath, branch, openCodeConfigName, useWorktree, provider } = body
22+
2323
if (!repoUrl && !localPath) {
2424
return c.json({ error: 'Either repoUrl or localPath is required' }, 400)
2525
}
26+
27+
logger.info(`Creating repo - URL: ${repoUrl}, Provider: ${provider || 'auto-detect'}`)
2628

2729
let repo
2830
if (localPath) {

backend/src/routes/settings.ts

Lines changed: 10 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
import { logger } from '../utils/logger'
1313
import { opencodeServerManager } from '../services/opencode-single-server'
1414
import { DEFAULT_AGENTS_MD } from '../index'
15-
import { createGitHubGitEnv } from '../utils/git-auth'
1615
import { exec } from 'child_process'
1716
import { promisify } from 'util'
1817

@@ -46,9 +45,7 @@ const UpdateCustomCommandSchema = z.object({
4645
promptTemplate: z.string().min(1).max(10000),
4746
})
4847

49-
const ValidateGitTokenSchema = z.object({
50-
gitToken: z.string(),
51-
})
48+
5249

5350
const ConnectMcpDirectorySchema = z.object({
5451
directory: z.string().min(1),
@@ -86,13 +83,16 @@ export function createSettingsRoutes(db: Database) {
8683
const settings = settingsService.updateSettings(validated.preferences, userId)
8784

8885
let serverRestarted = false
89-
if (validated.preferences.gitToken !== undefined &&
90-
validated.preferences.gitToken !== currentSettings.preferences.gitToken) {
91-
logger.info('GitHub token changed, restarting OpenCode server')
92-
await opencodeServerManager.restart()
93-
serverRestarted = true
86+
if (validated.preferences.gitCredentials !== undefined) {
87+
const currentCreds = JSON.stringify(currentSettings.preferences.gitCredentials || [])
88+
const newCreds = JSON.stringify(validated.preferences.gitCredentials)
89+
if (currentCreds !== newCreds) {
90+
logger.info('Git credentials changed, restarting OpenCode server')
91+
await opencodeServerManager.restart()
92+
serverRestarted = true
93+
}
9494
}
95-
95+
9696
return c.json({ ...settings, serverRestarted })
9797
} catch (error) {
9898
logger.error('Failed to update settings:', error)
@@ -459,72 +459,6 @@ export function createSettingsRoutes(db: Database) {
459459
}
460460
})
461461

462-
app.post('/validate-git-token', async (c) => {
463-
try {
464-
const body = await c.req.json()
465-
const { gitToken } = ValidateGitTokenSchema.parse(body)
466-
467-
if (!gitToken) {
468-
return c.json({ valid: true, message: 'No token provided' })
469-
}
470-
471-
// Test the token by trying to access a public GitHub repo via git ls-remote
472-
const testRepoUrl = 'https://github.com/octocat/Hello-World.git'
473-
const env = createGitHubGitEnv(gitToken)
474-
475-
try {
476-
await execAsync(`git ls-remote ${testRepoUrl}`, {
477-
env: { ...process.env, ...env },
478-
timeout: 10000
479-
})
480-
481-
// If command succeeded (exit code 0), token is valid
482-
// stderr may contain warnings but that's ok
483-
return c.json({
484-
valid: true,
485-
message: 'Token is valid'
486-
})
487-
} catch (error) {
488-
logger.error('Git token validation failed:', error)
489-
490-
if (error instanceof Error) {
491-
const errorMsg = error.message.toLowerCase()
492-
493-
if (errorMsg.includes('authentication failed') ||
494-
errorMsg.includes('not authorized') ||
495-
errorMsg.includes('invalid username or token') ||
496-
errorMsg.includes('password authentication is not supported') ||
497-
errorMsg.includes('401') ||
498-
errorMsg.includes('403') ||
499-
errorMsg.includes('code 128')) {
500-
return c.json({
501-
valid: false,
502-
message: 'Invalid GitHub token. Please check your token and permissions.'
503-
})
504-
}
505-
506-
if (errorMsg.includes('timeout') || errorMsg.includes('network')) {
507-
return c.json({
508-
valid: false,
509-
message: 'Network error - could not validate token. Please try again.'
510-
})
511-
}
512-
}
513-
514-
return c.json({
515-
valid: false,
516-
message: 'Failed to validate token: ' + (error instanceof Error ? error.message : 'Unknown error')
517-
})
518-
}
519-
} catch (error) {
520-
logger.error('Token validation endpoint error:', error)
521-
if (error instanceof z.ZodError) {
522-
return c.json({ error: 'Invalid request data', details: error.issues }, 400)
523-
}
524-
return c.json({ error: 'Failed to validate token' }, 500)
525-
}
526-
})
527-
528462
// MCP directory-aware endpoints
529463
app.post('/mcp/:name/connectdirectory', async (c) => {
530464
try {

backend/src/services/git-operations.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { logger } from '../utils/logger'
33
import { SettingsService } from './settings'
44
import type { Database } from 'bun:sqlite'
55
import path from 'path'
6-
import { createGitHubGitEnv, createNoPromptGitEnv } from '../utils/git-auth'
6+
import { createGitEnv, createNoPromptGitEnv } from '../utils/git-auth'
77

88
async function hasCommits(repoPath: string): Promise<boolean> {
99
try {
@@ -18,15 +18,11 @@ function getGitEnvironment(database: Database): Record<string, string> {
1818
try {
1919
const settingsService = new SettingsService(database)
2020
const settings = settingsService.getSettings('default')
21-
const gitToken = settings.preferences.gitToken
21+
const gitCredentials = settings.preferences.gitCredentials || []
2222

23-
if (gitToken) {
24-
return createGitHubGitEnv(gitToken)
25-
}
26-
27-
return createNoPromptGitEnv()
23+
return createGitEnv(gitCredentials)
2824
} catch (error) {
29-
logger.warn('Failed to get git token from settings:', error)
25+
logger.warn('Failed to get git credentials from settings:', error)
3026
return createNoPromptGitEnv()
3127
}
3228
}

backend/src/services/opencode-single-server.ts

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import { spawn, execSync } from 'child_process'
22
import path from 'path'
33
import { logger } from '../utils/logger'
4-
import { createGitHubGitEnv, createNoPromptGitEnv } from '../utils/git-auth'
4+
import { createGitEnv, createNoPromptGitEnv } from '../utils/git-auth'
55
import { SettingsService } from './settings'
66
import { getWorkspacePath, getOpenCodeConfigFilePath, ENV } from '@opencode-manager/shared/config/env'
77
import type { Database } from 'bun:sqlite'
8+
import type { GitCredential } from '../utils/git-auth'
89

910
const OPENCODE_SERVER_PORT = ENV.OPENCODE.PORT
1011
const OPENCODE_SERVER_DIRECTORY = getWorkspacePath()
@@ -54,18 +55,18 @@ class OpenCodeServerManager {
5455
}
5556

5657
const isDevelopment = ENV.SERVER.NODE_ENV !== 'production'
57-
58-
let gitToken = ''
58+
59+
let gitCredentials: GitCredential[] = []
5960
if (this.db) {
6061
try {
6162
const settingsService = new SettingsService(this.db)
6263
const settings = settingsService.getSettings('default')
63-
gitToken = settings.preferences.gitToken || ''
64+
gitCredentials = settings.preferences.gitCredentials || []
6465
} catch (error) {
65-
logger.warn('Failed to get git token from settings:', error)
66+
logger.warn('Failed to get git credentials from settings:', error)
6667
}
6768
}
68-
69+
6970
const existingProcesses = await this.findProcessesByPort(OPENCODE_SERVER_PORT)
7071
if (existingProcesses.length > 0) {
7172
logger.info(`OpenCode server already running on port ${OPENCODE_SERVER_PORT}`)
@@ -105,7 +106,7 @@ class OpenCodeServerManager {
105106
logger.info(`OpenCode XDG_CONFIG_HOME: ${path.join(OPENCODE_SERVER_DIRECTORY, '.config')}`)
106107
logger.info(`OpenCode will use ?directory= parameter for session isolation`)
107108

108-
const gitEnv = gitToken ? createGitHubGitEnv(gitToken) : createNoPromptGitEnv()
109+
const gitEnv = createGitEnv(gitCredentials)
109110

110111
let stderrOutput = ''
111112

backend/src/services/repo.ts

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { Database } from 'bun:sqlite'
55
import type { Repo, CreateRepoInput } from '../types/repo'
66
import { logger } from '../utils/logger'
77
import { SettingsService } from './settings'
8-
import { createGitEnvForRepoUrl, createNoPromptGitEnv, createGitHubGitEnv } from '../utils/git-auth'
8+
import { createGitEnv, createNoPromptGitEnv, createGitHubGitEnv, isGitHubHttpsUrl } from '../utils/git-auth'
99
import { getReposPath } from '@opencode-manager/shared/config/env'
1010
import path from 'path'
1111

@@ -34,27 +34,36 @@ async function executeGitWithFallback(
3434
options: GitCommandOptions = {}
3535
): Promise<string> {
3636
const { cwd, env = createNoPromptGitEnv(), silent } = options
37-
37+
3838
try {
3939
return await executeCommand(cmd, { cwd, env, silent })
4040
} catch (error: any) {
4141
if (!isAuthenticationError(error)) {
4242
throw error
4343
}
44-
45-
logger.warn(`Git command failed with auth, trying gh auth fallback`)
44+
45+
logger.warn(`Git command failed with auth, trying CLI fallbacks`)
46+
47+
const url = cmd.find(arg => arg.includes('http://') || arg.includes('https://'))
48+
if (!url) {
49+
return await executeCommand(cmd, { cwd, env: createNoPromptGitEnv(), silent })
50+
}
51+
4652
try {
47-
const ghToken = (await executeCommand(['gh', 'auth', 'token'])).trim()
48-
const ghEnv = createGitHubGitEnv(ghToken)
49-
return await executeCommand(cmd, { cwd, env: ghEnv, silent })
50-
} catch (ghError: any) {
51-
if (!isAuthenticationError(ghError)) {
52-
throw ghError
53+
if (isGitHubHttpsUrl(url)) {
54+
logger.warn(`Detected GitHub URL, trying gh auth token`)
55+
const ghToken = (await executeCommand(['gh', 'auth', 'token'])).trim()
56+
const ghEnv = createGitHubGitEnv(ghToken)
57+
return await executeCommand(cmd, { cwd, env: ghEnv, silent })
5358
}
54-
55-
logger.warn(`Git command failed with gh auth, trying without auth (public repo)`)
56-
return await executeCommand(cmd, { cwd, env: createNoPromptGitEnv(), silent })
59+
60+
61+
} catch (cliError: any) {
62+
logger.warn(`CLI auth fallback failed:`, cliError.message)
5763
}
64+
65+
logger.warn(`All auth fallbacks failed, trying without auth (public repo)`)
66+
return await executeCommand(cmd, { cwd, env: createNoPromptGitEnv(), silent })
5867
}
5968
}
6069

@@ -87,17 +96,13 @@ async function safeGetCurrentBranch(repoPath: string): Promise<string | null> {
8796
}
8897
}
8998

90-
function getGitEnv(database: Database, repoUrl?: string | null): Record<string, string> {
99+
function getGitEnv(database: Database): Record<string, string> {
91100
try {
92101
const settingsService = new SettingsService(database)
93102
const settings = settingsService.getSettings('default')
94-
const gitToken = settings.preferences.gitToken
95-
96-
if (!repoUrl) {
97-
return createNoPromptGitEnv()
98-
}
103+
const gitCredentials = settings.preferences.gitCredentials || []
99104

100-
return createGitEnvForRepoUrl(repoUrl, gitToken)
105+
return createGitEnv(gitCredentials)
101106
} catch {
102107
return createNoPromptGitEnv()
103108
}
@@ -225,7 +230,7 @@ export async function cloneRepo(
225230
const repo = db.createRepo(database, createRepoInput)
226231

227232
try {
228-
const env = getGitEnv(database, normalizedRepoUrl)
233+
const env = getGitEnv(database)
229234

230235
if (shouldUseWorktree) {
231236
logger.info(`Creating worktree for branch: ${branch}`)
@@ -408,7 +413,7 @@ export async function getCurrentBranch(repo: Repo): Promise<string | null> {
408413
export async function listBranches(database: Database, repo: Repo): Promise<{ local: string[], all: string[], current: string | null }> {
409414
try {
410415
const repoPath = path.resolve(getReposPath(), repo.localPath)
411-
const env = getGitEnv(database, repo.repoUrl)
416+
const env = getGitEnv(database)
412417

413418
if (!repo.isLocal) {
414419
try {
@@ -457,7 +462,7 @@ export async function switchBranch(database: Database, repoId: number, branch: s
457462

458463
try {
459464
const repoPath = path.resolve(getReposPath(), repo.localPath)
460-
const env = getGitEnv(database, repo.repoUrl)
465+
const env = getGitEnv(database)
461466

462467
const sanitizedBranch = branch
463468
.replace(/^refs\/heads\//, '')
@@ -537,7 +542,7 @@ export async function pullRepo(database: Database, repoId: number): Promise<void
537542
}
538543

539544
try {
540-
const env = getGitEnv(database, repo.repoUrl)
545+
const env = getGitEnv(database)
541546

542547
logger.info(`Pulling repo: ${repo.repoUrl}`)
543548
await executeCommand(['git', '-C', path.resolve(getReposPath(), repo.localPath), 'pull'], { env })

0 commit comments

Comments
 (0)