/ src / features / github-publishing / settings-section.ts
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  }