settings-section.ts
1 /** 2 * GitHub Publishing Settings Section 3 * 4 * Feature-owned settings UI for publishing DreamSongs to GitHub Pages. 5 * Mirrors DreamNode repos to GitHub and creates static DreamSong sites. 6 */ 7 8 import type InterBrainPlugin from '../../main'; 9 import type { FeatureStatus } from '../settings/settings-status-service'; 10 import { SettingsStatusService } from '../settings/settings-status-service'; 11 12 interface GitHubIdentity { 13 username: string; 14 ghVersion: string; 15 } 16 17 /** 18 * Get environment with extended PATH for gh CLI detection 19 * Obsidian/Electron may not have the full shell PATH 20 */ 21 function getExtendedEnv(): Record<string, string> { 22 const nodeProcess = (globalThis as any).process; 23 const env = { ...nodeProcess?.env }; 24 25 // Add common gh install locations to PATH 26 const extraPaths = [ 27 '/opt/homebrew/bin', // macOS ARM Homebrew 28 '/usr/local/bin', // macOS Intel Homebrew / Linux 29 '/usr/bin', // System 30 `${env.HOME}/.local/bin`, // Linux user installs 31 ].filter(Boolean); 32 33 env.PATH = [...extraPaths, env.PATH].join(':'); 34 return env; 35 } 36 37 /** 38 * Get GitHub identity (username and gh version) 39 */ 40 async function getGitHubIdentity(): Promise<GitHubIdentity | null> { 41 const { exec } = require('child_process'); 42 const { promisify } = require('util'); 43 const execAsync = promisify(exec); 44 const env = getExtendedEnv(); 45 46 try { 47 // Get gh version 48 const versionResult = await execAsync('gh --version', { env }); 49 const versionMatch = versionResult.stdout.match(/gh version ([\d.]+)/); 50 const ghVersion = versionMatch ? versionMatch[1] : 'unknown'; 51 52 // Get authenticated username via gh api 53 const userResult = await execAsync('gh api user --jq .login', { env }); 54 const username = userResult.stdout.trim(); 55 56 if (username) { 57 return { username, ghVersion }; 58 } 59 return null; 60 } catch { 61 return null; 62 } 63 } 64 65 /** 66 * Check GitHub feature status 67 */ 68 export async function checkGitHubStatus(): Promise<FeatureStatus> { 69 const { exec } = require('child_process'); 70 const { promisify } = require('util'); 71 const execAsync = promisify(exec); 72 const env = getExtendedEnv(); 73 74 try { 75 // Step 1: Check if gh CLI is installed 76 let ghVersion: string | null = null; 77 try { 78 const versionResult = await execAsync('gh --version', { env }); 79 const versionMatch = versionResult.stdout.match(/gh version ([\d.]+)/); 80 ghVersion = versionMatch ? versionMatch[1] : 'installed'; 81 } catch { 82 // gh not installed 83 return { 84 available: false, 85 status: 'not-installed', 86 message: 'GitHub CLI not installed', 87 details: 'Re-run the install script to set up gh CLI' 88 }; 89 } 90 91 // Step 2: Check if authenticated 92 try { 93 const userResult = await execAsync('gh api user --jq .login', { env }); 94 const username = userResult.stdout.trim(); 95 96 if (username) { 97 return { 98 available: true, 99 status: 'ready', 100 message: `Ready (${username})`, 101 details: `gh CLI v${ghVersion} • Authenticated as ${username}` 102 }; 103 } 104 } catch { 105 // Not authenticated 106 return { 107 available: false, 108 status: 'warning', 109 message: 'Not authenticated', 110 details: 'Run "gh auth login" to authenticate with GitHub' 111 }; 112 } 113 114 // gh installed but couldn't get user 115 return { 116 available: true, 117 status: 'warning', 118 message: 'gh CLI installed, auth status unknown', 119 details: `gh CLI v${ghVersion}` 120 }; 121 122 } catch (error) { 123 return { 124 available: false, 125 status: 'error', 126 message: 'Error checking GitHub CLI', 127 details: error instanceof Error ? error.message : 'Unknown error' 128 }; 129 } 130 } 131 132 /** 133 * Create the GitHub publishing settings section 134 */ 135 export function createGitHubSettingsSection( 136 containerEl: HTMLElement, 137 _plugin: InterBrainPlugin, 138 status: FeatureStatus | undefined 139 ): void { 140 const header = containerEl.createEl('h2', { text: '🧬 GitHub Publishing' }); 141 header.id = 'github-section'; 142 143 if (status) { 144 createStatusDisplay(containerEl, status); 145 } 146 147 containerEl.createEl('p', { 148 text: 'Publish DreamSongs as static GitHub Pages sites. Also mirrors DreamNode repositories to GitHub as open source.', 149 cls: 'setting-item-description' 150 }); 151 152 // Show GitHub identity if available 153 if (status?.available && status.status === 'ready') { 154 createGitHubIdentityDisplay(containerEl); 155 } 156 157 // Show install script link if not installed 158 if (status?.status === 'not-installed') { 159 createInstallScriptLink(containerEl); 160 } 161 } 162 163 /** 164 * Create GitHub identity display with copy button 165 */ 166 function createGitHubIdentityDisplay(containerEl: HTMLElement): void { 167 // Create placeholder for identity (will be populated asynchronously) 168 const identityPlaceholder = containerEl.createDiv({ cls: 'interbrain-github-identity-placeholder' }); 169 170 getGitHubIdentity().then((identity) => { 171 if (identity) { 172 identityPlaceholder.empty(); 173 identityPlaceholder.addClass('interbrain-github-identity'); 174 identityPlaceholder.removeClass('interbrain-github-identity-placeholder'); 175 176 identityPlaceholder.createEl('p', { text: 'Your GitHub Identity:' }); 177 178 const usernameContainer = identityPlaceholder.createDiv({ cls: 'github-username-container' }); 179 usernameContainer.style.display = 'flex'; 180 usernameContainer.style.alignItems = 'center'; 181 usernameContainer.style.gap = '8px'; 182 usernameContainer.style.marginTop = '8px'; 183 184 usernameContainer.createSpan({ text: 'Username: ' }); 185 usernameContainer.createEl('code', { text: identity.username }); 186 187 // Add copy button 188 const copyButton = usernameContainer.createEl('button', { 189 text: '📋 Copy', 190 cls: 'github-copy-button' 191 }); 192 copyButton.addEventListener('click', () => { 193 navigator.clipboard.writeText(identity.username).then(() => { 194 copyButton.textContent = '✅ Copied!'; 195 setTimeout(() => { 196 copyButton.textContent = '📋 Copy'; 197 }, 2000); 198 }).catch(() => { 199 copyButton.textContent = '❌ Failed'; 200 setTimeout(() => { 201 copyButton.textContent = '📋 Copy'; 202 }, 2000); 203 }); 204 }); 205 206 // Show gh version 207 const versionText = identityPlaceholder.createEl('p', { 208 cls: 'setting-item-description' 209 }); 210 versionText.style.marginTop = '4px'; 211 versionText.style.fontSize = '12px'; 212 versionText.textContent = `gh CLI version ${identity.ghVersion}`; 213 } 214 }).catch(() => { 215 // Identity not available, remove placeholder 216 identityPlaceholder.remove(); 217 }); 218 } 219 220 /** 221 * Create link to install script section 222 */ 223 function createInstallScriptLink(containerEl: HTMLElement): void { 224 const linkDiv = containerEl.createDiv({ cls: 'interbrain-install-link' }); 225 linkDiv.style.marginTop = '12px'; 226 227 const linkText = linkDiv.createEl('p'); 228 linkText.createSpan({ text: '💡 ' }); 229 230 const link = linkText.createEl('a', { 231 text: 'Re-run the install script', 232 href: '#install-script-section' 233 }); 234 link.addEventListener('click', (e) => { 235 e.preventDefault(); 236 const section = document.getElementById('install-script-section'); 237 if (section) { 238 section.scrollIntoView({ behavior: 'smooth', block: 'start' }); 239 } 240 }); 241 242 linkText.createSpan({ text: ' to set up the GitHub CLI.' }); 243 } 244 245 /** 246 * Helper: Create status display for a feature 247 */ 248 function createStatusDisplay(containerEl: HTMLElement, status: FeatureStatus): void { 249 const statusDiv = containerEl.createDiv({ cls: 'interbrain-status-display' }); 250 251 const icon = SettingsStatusService.getStatusIcon(status.status); 252 const colorClass = SettingsStatusService.getStatusColor(status.status); 253 254 const statusText = statusDiv.createEl('p', { 255 cls: `interbrain-status-text ${colorClass}` 256 }); 257 statusText.createSpan({ text: `${icon} Status: ` }); 258 statusText.createEl('strong', { text: status.message }); 259 260 if (status.details) { 261 statusDiv.createEl('p', { 262 text: status.details, 263 cls: 'interbrain-status-details' 264 }); 265 } 266 }