import * as plugins from '../plugins.ts'; import { logger } from '../logging.ts'; import type * as interfaces from '../../ts_interfaces/index.ts'; import { BaseProvider, GiteaProvider, GitLabProvider } from '../providers/index.ts'; import type { StorageManager } from '../storage/index.ts'; const LEGACY_CONNECTIONS_FILE = './.nogit/connections.json'; const CONNECTIONS_PREFIX = '/connections/'; const KEYCHAIN_PREFIX = 'keychain:'; /** * Manages provider connections — persists each connection as an * individual JSON file via StorageManager. Tokens are stored in * the OS keychain (or encrypted file fallback) via SmartSecret. */ export class ConnectionManager { private connections: interfaces.data.IProviderConnection[] = []; private storageManager: StorageManager; private smartSecret: plugins.smartsecret.SmartSecret; /** Resolves when background connection health checks complete */ public healthCheckDone: Promise = Promise.resolve(); constructor(storageManager: StorageManager, smartSecret: plugins.smartsecret.SmartSecret) { this.storageManager = storageManager; this.smartSecret = smartSecret; } async init(): Promise { await this.migrateLegacyFile(); await this.loadConnections(); // Auto-test all connections in the background this.healthCheckDone = this.testAllConnections(); } /** * Tests all loaded connections in the background and updates their status. * Fire-and-forget — does not block startup. */ private async testAllConnections(): Promise { for (const conn of this.connections) { if (conn.status === 'paused') continue; try { const provider = this.getProvider(conn.id); const result = await provider.testConnection(); conn.status = result.ok ? 'connected' : 'error'; await this.persistConnection(conn); } catch { conn.status = 'error'; } } } /** * One-time migration from the legacy .nogit/connections.json file. */ private async migrateLegacyFile(): Promise { try { const text = await Deno.readTextFile(LEGACY_CONNECTIONS_FILE); const legacy: interfaces.data.IProviderConnection[] = JSON.parse(text); if (legacy.length > 0) { logger.info(`Migrating ${legacy.length} connection(s) from legacy file...`); for (const conn of legacy) { await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, conn); } // Rename legacy file so migration doesn't repeat await Deno.rename(LEGACY_CONNECTIONS_FILE, LEGACY_CONNECTIONS_FILE + '.migrated'); logger.success('Legacy connections migrated successfully'); } } catch { // No legacy file or already migrated — nothing to do } } private async loadConnections(): Promise { const keys = await this.storageManager.list(CONNECTIONS_PREFIX); this.connections = []; for (const key of keys) { const conn = await this.storageManager.getJSON(key); if (conn) { // Migrate legacy baseGroup/baseGroupId property names if ((conn as any).baseGroup !== undefined && conn.groupFilter === undefined) { conn.groupFilter = (conn as any).baseGroup; delete (conn as any).baseGroup; } if ((conn as any).baseGroupId !== undefined && conn.groupFilterId === undefined) { conn.groupFilterId = (conn as any).baseGroupId; delete (conn as any).baseGroupId; } if (conn.token.startsWith(KEYCHAIN_PREFIX)) { // Token is in keychain — retrieve it const realToken = await this.smartSecret.getSecret(conn.id); if (realToken) { conn.token = realToken; } else { logger.warn(`Could not retrieve token for connection ${conn.id} from keychain`); } } else if (conn.token && conn.token !== '***') { // Plaintext token found — auto-migrate to keychain await this.migrateTokenToKeychain(conn); } this.connections.push(conn); } } if (this.connections.length > 0) { logger.info(`Loaded ${this.connections.length} connection(s)`); } else { logger.debug('No existing connections found, starting fresh'); } } /** * Migrates a plaintext token to keychain storage. */ private async migrateTokenToKeychain( conn: interfaces.data.IProviderConnection, ): Promise { try { await this.smartSecret.setSecret(conn.id, conn.token); // Save sentinel to JSON file const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` }; await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn); logger.info(`Migrated token for connection "${conn.name}" to keychain`); } catch (err) { logger.warn(`Failed to migrate token for ${conn.id} to keychain: ${err}`); } } private async persistConnection(conn: interfaces.data.IProviderConnection): Promise { // Store real token in keychain await this.smartSecret.setSecret(conn.id, conn.token); // Save JSON with sentinel value const jsonConn = { ...conn, token: `${KEYCHAIN_PREFIX}${conn.id}` }; await this.storageManager.setJSON(`${CONNECTIONS_PREFIX}${conn.id}.json`, jsonConn); } private async removeConnection(id: string): Promise { await this.smartSecret.deleteSecret(id); await this.storageManager.delete(`${CONNECTIONS_PREFIX}${id}.json`); } getConnections(): interfaces.data.IProviderConnection[] { return this.connections.map((c) => ({ ...c, token: '***' })); } getConnection(id: string): interfaces.data.IProviderConnection | undefined { return this.connections.find((c) => c.id === id); } async createConnection( name: string, providerType: interfaces.data.TProviderType, baseUrl: string, token: string, groupFilter?: string, ): Promise { const connection: interfaces.data.IProviderConnection = { id: crypto.randomUUID(), name, providerType, baseUrl: baseUrl.replace(/\/+$/, ''), token, createdAt: Date.now(), status: 'disconnected', groupFilter: groupFilter || undefined, }; this.connections.push(connection); await this.persistConnection(connection); logger.success(`Connection created: ${name} (${providerType})`); return { ...connection, token: '***' }; } async updateConnection( id: string, updates: { name?: string; baseUrl?: string; token?: string; groupFilter?: string }, ): Promise { const conn = this.connections.find((c) => c.id === id); if (!conn) throw new Error(`Connection not found: ${id}`); if (updates.name) conn.name = updates.name; if (updates.baseUrl) conn.baseUrl = updates.baseUrl.replace(/\/+$/, ''); if (updates.token) conn.token = updates.token; if (updates.groupFilter !== undefined) { conn.groupFilter = updates.groupFilter || undefined; conn.groupFilterId = undefined; // Will be re-resolved on next test } await this.persistConnection(conn); return { ...conn, token: '***' }; } async deleteConnection(id: string): Promise { const idx = this.connections.findIndex((c) => c.id === id); if (idx === -1) throw new Error(`Connection not found: ${id}`); this.connections.splice(idx, 1); await this.removeConnection(id); logger.info(`Connection deleted: ${id}`); } async pauseConnection(id: string, paused: boolean): Promise { const conn = this.connections.find((c) => c.id === id); if (!conn) throw new Error(`Connection not found: ${id}`); conn.status = paused ? 'paused' : 'disconnected'; await this.persistConnection(conn); logger.info(`Connection ${paused ? 'paused' : 'resumed'}: ${conn.name}`); return { ...conn, token: '***' }; } async testConnection(id: string): Promise<{ ok: boolean; error?: string }> { const conn = this.connections.find((c) => c.id === id)!; if (conn.status === 'paused') { return { ok: false, error: 'Connection is paused' }; } const provider = this.getProvider(id); const result = await provider.testConnection(); conn.status = result.ok ? 'connected' : 'error'; // Resolve group filter ID if connection has a groupFilter if (result.ok && conn.groupFilter) { await this.resolveGroupFilterId(conn); } await this.persistConnection(conn); return result; } /** * Resolves a human-readable groupFilter to the provider-specific group ID. */ private async resolveGroupFilterId(conn: interfaces.data.IProviderConnection): Promise { if (!conn.groupFilter) { conn.groupFilterId = undefined; return; } try { if (conn.providerType === 'gitlab') { const gitlabClient = new plugins.gitlabClient.GitLabClient(conn.baseUrl, conn.token); const group = await gitlabClient.getGroupByPath(conn.groupFilter); conn.groupFilterId = String(group.id); logger.info(`Resolved group filter "${conn.groupFilter}" to ID ${conn.groupFilterId}`); } else { // For Gitea, the org name IS the ID conn.groupFilterId = conn.groupFilter; logger.info(`Group filter for Gitea connection set to org "${conn.groupFilterId}"`); } } catch (err) { logger.warn(`Failed to resolve group filter "${conn.groupFilter}": ${err}`); conn.groupFilterId = undefined; } } /** * Factory: returns the correct provider instance for a connection ID */ getProvider(connectionId: string): BaseProvider { const conn = this.connections.find((c) => c.id === connectionId); if (!conn) throw new Error(`Connection not found: ${connectionId}`); switch (conn.providerType) { case 'gitea': return new GiteaProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId); case 'gitlab': return new GitLabProvider(conn.id, conn.baseUrl, conn.token, conn.groupFilterId); default: throw new Error(`Unknown provider type: ${conn.providerType}`); } } }