github-service.ts
1 /** 2 * GitHub Sharing Service 3 * 4 * Implements GitHub integration as fallback sharing mechanism and broadcast layer. 5 * Uses GitHub CLI (`gh`) for repo operations and GitHub API for Pages setup. 6 * 7 * Philosophy: "GitHub for sharing, Radicle for collaboration" 8 * - Fallback for Windows users (Radicle not yet compatible) 9 * - Public broadcast layer via GitHub Pages 10 * - Friend-to-friend sharing via Obsidian URI protocol 11 */ 12 13 import { exec } from 'child_process'; 14 import { promisify } from 'util'; 15 import * as path from 'path'; 16 import * as fs from 'fs'; 17 import { sanitizeTitleToPascalCase } from '../../dreamnode/utils/title-sanitization'; 18 import { URIHandlerService } from '../../uri-handler'; 19 import { UDDService } from '../../dreamnode/services/udd-service'; 20 21 const execAsync = promisify(exec); 22 23 // Image optimization settings 24 const IMAGE_MAX_WIDTH = 1920; 25 const IMAGE_QUALITY = 80; 26 27 export interface GitHubShareResult { 28 /** GitHub repository URL */ 29 repoUrl: string; 30 31 /** GitHub Pages URL (if Pages enabled) */ 32 pagesUrl?: string; 33 34 /** Obsidian URI for one-click cloning */ 35 obsidianUri: string; 36 } 37 38 interface SubmoduleInfo { 39 name: string; 40 path: string; // Full path to actual git repo (from url field) 41 relativePath: string; // Relative path within parent (from path field) 42 url: string; // URL from .gitmodules 43 } 44 45 export class GitHubService { 46 private ghPath: string | null = null; 47 private pluginDir: string | null = null; 48 private sharp: any = undefined; // undefined = not yet loaded, null = failed to load 49 50 /** 51 * Set the plugin directory path (must be called during plugin initialization) 52 */ 53 setPluginDir(dir: string): void { 54 this.pluginDir = dir; 55 } 56 57 /** 58 * Lazily load sharp for image optimization 59 * Returns null if sharp is not available (graceful fallback to copy) 60 */ 61 private getSharp(): any { 62 if (this.sharp === undefined) { 63 try { 64 this.sharp = require('sharp'); 65 } catch { 66 console.warn('GitHubService: sharp not available, images will not be optimized'); 67 this.sharp = null; 68 } 69 } 70 return this.sharp; 71 } 72 73 /** 74 * Generate favicon from an image using sharp 75 * Creates a 32x32 PNG favicon suitable for browsers 76 */ 77 private async generateFavicon(srcPath: string, destPath: string): Promise<boolean> { 78 const sharp = this.getSharp(); 79 if (!sharp) { 80 return false; 81 } 82 83 const ext = path.extname(srcPath).toLowerCase(); 84 85 // Only generate favicon from image files 86 if (!['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(ext)) { 87 return false; 88 } 89 90 try { 91 await sharp(srcPath) 92 .resize(32, 32, { 93 fit: 'cover', 94 position: 'center' 95 }) 96 .png() 97 .toFile(destPath); 98 99 return true; 100 } catch (error) { 101 console.warn(`GitHubService: Failed to generate favicon from ${srcPath}:`, error); 102 return false; 103 } 104 } 105 106 /** 107 * Optimize an image file using sharp 108 * Returns the output path (may have different extension if converted to webp) 109 */ 110 private async optimizeImage(srcPath: string, destPath: string): Promise<string> { 111 const sharp = this.getSharp(); 112 if (!sharp) { 113 // Fallback: just copy the file 114 fs.copyFileSync(srcPath, destPath); 115 return destPath; 116 } 117 118 const ext = path.extname(srcPath).toLowerCase(); 119 120 // Skip non-optimizable formats 121 if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) { 122 fs.copyFileSync(srcPath, destPath); 123 return destPath; 124 } 125 126 try { 127 // Convert to WebP for better compression (except if already webp) 128 const outputExt = ext === '.webp' ? '.webp' : '.webp'; 129 const outputPath = destPath.replace(/\.[^.]+$/, outputExt); 130 131 await sharp(srcPath) 132 .resize(IMAGE_MAX_WIDTH, null, { 133 withoutEnlargement: true, 134 fit: 'inside' 135 }) 136 .webp({ quality: IMAGE_QUALITY }) 137 .toFile(outputPath); 138 139 return outputPath; 140 } catch (error) { 141 console.warn(`GitHubService: Failed to optimize ${srcPath}, copying original:`, error); 142 fs.copyFileSync(srcPath, destPath); 143 return destPath; 144 } 145 } 146 147 /** 148 * Detect and cache the GitHub CLI path 149 */ 150 private async detectGhPath(): Promise<string> { 151 if (this.ghPath) { 152 return this.ghPath; 153 } 154 155 // Try with full path first (Homebrew default on Apple Silicon) 156 const pathsToTry = [ 157 '/opt/homebrew/bin/gh', 158 '/usr/local/bin/gh', 159 'gh' 160 ]; 161 162 for (const path of pathsToTry) { 163 try { 164 await execAsync(`${path} --version`); 165 this.ghPath = path; 166 return path; 167 } catch { 168 // Try next path 169 } 170 } 171 172 throw new Error('GitHub CLI not found in any standard location'); 173 } 174 175 /** 176 * Sanitize DreamNode title for GitHub repository name 177 * Uses unified PascalCase sanitization for consistency with file system 178 */ 179 private sanitizeRepoName(title: string): string { 180 return sanitizeTitleToPascalCase(title); 181 } 182 183 /** 184 * Check if a GitHub repository exists 185 */ 186 private async repoExists(repoName: string): Promise<boolean> { 187 try { 188 const ghPath = await this.detectGhPath(); 189 // Try to view repo (fails if doesn't exist) 190 // Note: cwd doesn't matter for GitHub API calls 191 await execAsync(`"${ghPath}" repo view ${repoName}`); 192 return true; 193 } catch { 194 return false; 195 } 196 } 197 198 /** 199 * Find an available repository name, adding -2, -3, etc. if needed 200 */ 201 private async findAvailableRepoName(title: string): Promise<string> { 202 let repoName = this.sanitizeRepoName(title); 203 204 // Handle edge case: sanitized name is empty 205 if (!repoName) { 206 repoName = 'dreamnode'; 207 } 208 209 // Check if base name is available 210 if (!(await this.repoExists(repoName))) { 211 return repoName; 212 } 213 214 // Try numbered variants (-2, -3, etc.) 215 let attempt = 2; 216 while (attempt < 100) { // Safety limit 217 const numberedName = `${repoName}-${attempt}`; 218 if (!(await this.repoExists(numberedName))) { 219 return numberedName; 220 } 221 attempt++; 222 } 223 224 throw new Error(`Could not find available name for "${title}" after 100 attempts`); 225 } 226 227 /** 228 * Read .udd file from DreamNode 229 * Delegates to UDDService for canonical .udd operations 230 */ 231 private async readUDD(dreamNodePath: string): Promise<any> { 232 return UDDService.readUDD(dreamNodePath); 233 } 234 235 /** 236 * Write .udd file to DreamNode 237 * Delegates to UDDService for canonical .udd operations 238 */ 239 private async writeUDD(dreamNodePath: string, udd: any): Promise<void> { 240 await UDDService.writeUDD(dreamNodePath, udd); 241 } 242 243 /** 244 * Get list of submodules from .gitmodules file 245 * Resolves GitHub URLs to local vault paths automatically 246 */ 247 async getSubmodules(dreamNodePath: string, vaultPath?: string): Promise<SubmoduleInfo[]> { 248 const gitmodulesPath = path.join(dreamNodePath, '.gitmodules'); 249 250 if (!fs.existsSync(gitmodulesPath)) { 251 return []; 252 } 253 254 const content = fs.readFileSync(gitmodulesPath, 'utf-8'); 255 const submodules: SubmoduleInfo[] = []; 256 257 // If vaultPath not provided, derive it from dreamNodePath 258 if (!vaultPath) { 259 vaultPath = path.dirname(dreamNodePath); 260 } 261 262 // Parse .gitmodules format 263 const lines = content.split('\n'); 264 let currentSubmodule: Partial<SubmoduleInfo> = {}; 265 266 for (const line of lines) { 267 const submoduleMatch = line.match(/\[submodule "([^"]+)"\]/); 268 if (submoduleMatch) { 269 if (currentSubmodule.name && currentSubmodule.url) { 270 submodules.push(currentSubmodule as SubmoduleInfo); 271 } 272 currentSubmodule = { name: submoduleMatch[1] }; 273 continue; 274 } 275 276 const pathMatch = line.match(/path = (.+)/); 277 if (pathMatch && currentSubmodule.name) { 278 currentSubmodule.relativePath = pathMatch[1].trim(); 279 } 280 281 const urlMatch = line.match(/url = (.+)/); 282 if (urlMatch && currentSubmodule.name && currentSubmodule.relativePath) { 283 const url = urlMatch[1].trim(); 284 currentSubmodule.url = url; 285 286 // Resolve URL to local path 287 if (url.startsWith('http') || url.startsWith('git@')) { 288 // GitHub/remote URL - use relativePath to find standalone repo 289 // The relativePath tells us the submodule directory name (e.g., "Thunderstorm-Generator-UPDATED-...") 290 // The standalone repo should have the same name in the vault root 291 const localPath = path.join(vaultPath, currentSubmodule.relativePath); 292 293 if (fs.existsSync(localPath)) { 294 currentSubmodule.path = localPath; 295 } else { 296 console.warn(`GitHubService: Local repo not found for ${url}: ${localPath}`); 297 currentSubmodule.path = url; // Fallback to URL 298 } 299 } else { 300 // Local path - use directly 301 currentSubmodule.path = url; 302 } 303 } 304 } 305 306 if (currentSubmodule.name && currentSubmodule.url) { 307 submodules.push(currentSubmodule as SubmoduleInfo); 308 } 309 310 return submodules; 311 } 312 313 /** 314 * Check if GitHub CLI is available and authenticated 315 */ 316 async isAvailable(): Promise<{ available: boolean; error?: string }> { 317 try { 318 const ghPath = await this.detectGhPath(); 319 320 // Check if authenticated (stderr goes to stdout for gh auth status) 321 const { stdout, stderr } = await execAsync(`${ghPath} auth status 2>&1`); 322 const output = stdout + stderr; 323 324 325 if (output.includes('Logged in to github.com')) { 326 return { available: true }; 327 } 328 329 return { 330 available: false, 331 error: 'GitHub CLI not authenticated. Run: gh auth login' 332 }; 333 } catch (error) { 334 const errorMessage = error instanceof Error ? error.message : String(error); 335 console.error('GitHubService: isAvailable check failed:', errorMessage); 336 337 if (errorMessage.includes('command not found') || errorMessage.includes('ENOENT') || errorMessage.includes('not found in any standard location')) { 338 return { 339 available: false, 340 error: 'GitHub CLI not found. Install from: https://cli.github.com or ensure /opt/homebrew/bin is in PATH' 341 }; 342 } 343 344 return { 345 available: false, 346 error: `GitHub CLI error: ${errorMessage}` 347 }; 348 } 349 } 350 351 /** 352 * Create public GitHub repository and push DreamNode content 353 */ 354 async createRepo(dreamNodePath: string, repoName: string): Promise<string> { 355 // Verify directory exists 356 if (!fs.existsSync(dreamNodePath)) { 357 throw new Error(`DreamNode path does not exist: ${dreamNodePath}`); 358 } 359 360 // Ensure it's a git repo 361 const gitDir = path.join(dreamNodePath, '.git'); 362 if (!fs.existsSync(gitDir)) { 363 throw new Error('DreamNode is not a git repository. Cannot share to GitHub.'); 364 } 365 366 try { 367 const ghPath = await this.detectGhPath(); 368 369 // Create public GitHub repository with provided name 370 const { stdout } = await execAsync( 371 `"${ghPath}" repo create ${repoName} --public --source="${dreamNodePath}" --remote=github --push`, 372 { cwd: dreamNodePath } 373 ); 374 375 // Extract repository URL from output 376 const match = stdout.match(/https:\/\/github\.com\/[^\s]+/); 377 if (!match) { 378 throw new Error('Failed to extract repository URL from gh output'); 379 } 380 381 const repoUrl = match[0]; 382 return repoUrl; 383 } catch (error) { 384 if (error instanceof Error) { 385 throw new Error(`Failed to create GitHub repository: ${error.message}`); 386 } 387 throw error; 388 } 389 } 390 391 /** 392 * Enable GitHub Pages for repository (serves from gh-pages branch) 393 */ 394 async setupPages(repoUrl: string): Promise<string> { 395 // Extract owner/repo from URL 396 const match = repoUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 397 if (!match) { 398 throw new Error(`Invalid GitHub URL: ${repoUrl}`); 399 } 400 401 const [, owner, repo] = match; 402 const cleanRepo = repo.replace(/\.git$/, ''); 403 const pagesUrl = `https://${owner}.github.io/${cleanRepo}`; 404 405 try { 406 const ghPath = await this.detectGhPath(); 407 408 // Enable GitHub Pages via API - serve from gh-pages branch 409 // Note: gh CLI doesn't have native pages command, so we use gh api 410 await execAsync( 411 `"${ghPath}" api -X POST "repos/${owner}/${cleanRepo}/pages" -f source[branch]=gh-pages -f source[path]=/` 412 ); 413 414 return pagesUrl; 415 } catch (error) { 416 if (error instanceof Error) { 417 const errorMsg = error.message.toLowerCase(); 418 419 // Pages already exists - this is fine, return the URL 420 if (errorMsg.includes('already exists') || 421 errorMsg.includes('409') || 422 errorMsg.includes('unexpected end of json')) { 423 return pagesUrl; 424 } 425 426 // Other error - log but don't throw (non-fatal) 427 console.warn(`GitHubService: Could not configure Pages API (site will still work):`, error.message); 428 return pagesUrl; 429 } 430 throw error; 431 } 432 } 433 434 /** 435 * Generate Obsidian URI for cloning from GitHub 436 * Delegates to URIHandlerService for canonical URL generation 437 */ 438 generateObsidianURI(repoUrl: string): string { 439 return URIHandlerService.generateGitHubCloneLink('', repoUrl); 440 } 441 442 /** 443 * Clone DreamNode from GitHub URL 444 */ 445 async clone(githubUrl: string, destinationPath: string): Promise<void> { 446 try { 447 // Ensure parent directory exists 448 const parentDir = path.dirname(destinationPath); 449 if (!fs.existsSync(parentDir)) { 450 fs.mkdirSync(parentDir, { recursive: true }); 451 } 452 453 // Clone with --single-branch (avoids gh-pages and other branches) 454 // Try main first, fall back to master for older repos, then let git auto-detect default 455 try { 456 await execAsync(`git clone --single-branch -b main "${githubUrl}" "${destinationPath}"`); 457 } catch { 458 // If main branch doesn't exist, try master (older repos like octocat/Hello-World) 459 try { 460 await execAsync(`git clone --single-branch -b master "${githubUrl}" "${destinationPath}"`); 461 } catch { 462 // If both fail, let git auto-detect the default branch 463 await execAsync(`git clone --single-branch "${githubUrl}" "${destinationPath}"`); 464 } 465 } 466 } catch (error) { 467 if (error instanceof Error) { 468 throw new Error(`Failed to clone from GitHub: ${error.message}`); 469 } 470 throw error; 471 } 472 } 473 474 // ============================================================ 475 // GITHUB PAGES PUBLISHING - UNIFIED PIPELINE 476 // ============================================================ 477 // 478 // The publishing pipeline has three stages: 479 // 1. prepareContentBlocks() - Parse canvas/content, resolve media paths 480 // 2. buildStaticSite() - Generate HTML/assets in temp directory 481 // 3. deployToGhPages() - Commit to local gh-pages branch, push to remote 482 // 483 // Both "Publish" and "Update" commands use the same pipeline. 484 // ============================================================ 485 486 /** 487 * Prepare content blocks from DreamNode for static site generation 488 * Parses canvas files and resolves media to absolute paths 489 * 490 * Fallback hierarchy: DreamSong (canvas) → DreamTalk → README.md 491 */ 492 private async prepareContentBlocks( 493 dreamNodePath: string, 494 dreamNodeUuid: string 495 ): Promise<any[]> { 496 const { parseCanvasToBlocks } = await import('../../dreamweaving/dreamsong/index'); 497 498 const files = fs.readdirSync(dreamNodePath); 499 const canvasFiles = files.filter((f: string) => f.endsWith('.canvas')); 500 const vaultPath = path.dirname(dreamNodePath); 501 502 // PRIMARY: DreamSong (canvas file) 503 if (canvasFiles.length > 0) { 504 const canvasPath = path.join(dreamNodePath, canvasFiles[0]); 505 const canvasContent = fs.readFileSync(canvasPath, 'utf-8'); 506 const canvasData = JSON.parse(canvasContent); 507 const blocks = parseCanvasToBlocks(canvasData, dreamNodeUuid) as any[]; 508 509 // Resolve media paths and UUIDs 510 for (const block of blocks) { 511 if (block.media && block.media.src && 512 !block.media.src.startsWith('data:') && 513 !block.media.src.startsWith('http')) { 514 515 // Resolve source DreamNode UUID for cross-navigation 516 const resolvedUuid = await this.resolveSourceDreamNodeUuid(block.media.src, vaultPath); 517 if (resolvedUuid) { 518 block.media.sourceDreamNodeId = resolvedUuid; 519 } 520 521 // Store absolute path for file copy (NOT base64 embedding) 522 const mediaPath = path.join(vaultPath, block.media.src); 523 if (fs.existsSync(mediaPath)) { 524 block.media._absolutePath = mediaPath; 525 } else { 526 console.warn(`GitHubService: Media file not found: ${mediaPath}`); 527 } 528 } 529 } 530 531 return blocks; 532 } 533 534 // FALLBACK 1: DreamTalk - handled by buildStaticSite via .udd 535 const udd = await this.readUDD(dreamNodePath); 536 if (udd.dreamTalk) { 537 return []; // Empty blocks - buildStaticSite reads dreamTalk from .udd 538 } 539 540 // FALLBACK 2: README.md 541 const readmePath = path.join(dreamNodePath, 'README.md'); 542 if (fs.existsSync(readmePath)) { 543 const readmeContent = fs.readFileSync(readmePath, 'utf-8'); 544 545 // Simple markdown to HTML conversion 546 const htmlContent = readmeContent 547 .replace(/^### (.*$)/gim, '<h3>$1</h3>') 548 .replace(/^## (.*$)/gim, '<h2>$1</h2>') 549 .replace(/^# (.*$)/gim, '<h1>$1</h1>') 550 .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') 551 .replace(/\*(.*?)\*/g, '<em>$1</em>') 552 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>') 553 .split('\n\n') 554 .map(para => para.trim() ? `<p>${para.replace(/\n/g, '<br>')}</p>` : '') 555 .join(''); 556 557 return [{ 558 id: 'readme-fallback', 559 type: 'text', 560 text: htmlContent, 561 edges: [] 562 }]; 563 } 564 565 console.warn(`GitHubService: No content found - no DreamSong, DreamTalk, or README.md`); 566 return []; 567 } 568 569 /** 570 * Rebuild GitHub Pages for an already-published DreamNode 571 * Fast path - only rebuilds the static site, doesn't touch repo config 572 */ 573 async rebuildGitHubPages(dreamNodePath: string): Promise<void> { 574 const udd = await this.readUDD(dreamNodePath); 575 576 if (!udd.githubRepoUrl) { 577 throw new Error('DreamNode is not published to GitHub'); 578 } 579 580 // Prepare content and deploy 581 const blocks = await this.prepareContentBlocks(dreamNodePath, udd.uuid); 582 await this.buildStaticSite(dreamNodePath, udd.uuid, udd.title, blocks); 583 } 584 585 /** 586 * Build static DreamSong site for GitHub Pages 587 * @param blocks - DreamSongBlocks with media._absolutePath for files to copy 588 * @param vaultPath - Path to vault for resolving relative media paths 589 */ 590 async buildStaticSite( 591 dreamNodePath: string, 592 dreamNodeId: string, 593 dreamNodeName: string, 594 blocks: any[], // DreamSongBlock[] with _absolutePath metadata 595 _vaultPath?: string 596 ): Promise<string> { 597 try { 598 // Read .udd file to get metadata 599 const uddPath = path.join(dreamNodePath, '.udd'); 600 if (!fs.existsSync(uddPath)) { 601 throw new Error('.udd file not found'); 602 } 603 604 const uddContent = fs.readFileSync(uddPath, 'utf-8'); 605 const udd = JSON.parse(uddContent); 606 607 // Create output directory in system temp (outside the repo) 608 const tmpOs = require('os'); 609 const buildDir = path.join(tmpOs.tmpdir(), `dreamsong-build-${dreamNodeId}-${Date.now()}`); 610 if (!fs.existsSync(buildDir)) { 611 fs.mkdirSync(buildDir, { recursive: true }); 612 } 613 614 615 // Create media directory for copied files 616 const mediaDir = path.join(buildDir, 'media'); 617 fs.mkdirSync(mediaDir, { recursive: true }); 618 619 // Track used filenames to handle collisions 620 const usedFilenames = new Set<string>(); 621 622 // Helper to get unique filename 623 const getUniqueFilename = (originalPath: string): string => { 624 const ext = path.extname(originalPath); 625 const base = path.basename(originalPath, ext); 626 let filename = `${base}${ext}`; 627 let counter = 1; 628 629 while (usedFilenames.has(filename.toLowerCase())) { 630 filename = `${base}-${counter}${ext}`; 631 counter++; 632 } 633 634 usedFilenames.add(filename.toLowerCase()); 635 return filename; 636 }; 637 638 // Helper to check if file is an image that can be optimized 639 const isOptimizableImage = (filePath: string): boolean => { 640 const ext = path.extname(filePath).toLowerCase(); 641 return ['.jpg', '.jpeg', '.png', '.webp'].includes(ext); 642 }; 643 644 // Copy/optimize media files and update block references 645 for (const block of blocks) { 646 if (block.media && block.media._absolutePath) { 647 const absolutePath = block.media._absolutePath; 648 649 if (fs.existsSync(absolutePath)) { 650 let uniqueFilename = getUniqueFilename(absolutePath); 651 const destPath = path.join(mediaDir, uniqueFilename); 652 653 // Optimize images, copy other files directly 654 if (isOptimizableImage(absolutePath)) { 655 const outputPath = await this.optimizeImage(absolutePath, destPath); 656 // Update filename if extension changed (e.g., jpg -> webp) 657 uniqueFilename = path.basename(outputPath); 658 } else { 659 fs.copyFileSync(absolutePath, destPath); 660 } 661 662 // Update src to relative path (works in browser) 663 block.media.src = `./media/${uniqueFilename}`; 664 665 } else { 666 console.warn(`GitHubService: Media file not found: ${absolutePath}`); 667 } 668 669 // Clean up internal metadata before serialization 670 delete block.media._absolutePath; 671 } 672 } 673 674 675 // Handle DreamTalk media (copy/optimize file, not embed) 676 let dreamTalkMedia: any[] | undefined; 677 if (udd.dreamTalk) { 678 const dreamTalkPath = path.join(dreamNodePath, udd.dreamTalk); 679 if (fs.existsSync(dreamTalkPath)) { 680 let uniqueFilename = getUniqueFilename(dreamTalkPath); 681 const destPath = path.join(mediaDir, uniqueFilename); 682 683 // Optimize images, copy other files directly 684 if (isOptimizableImage(dreamTalkPath)) { 685 const outputPath = await this.optimizeImage(dreamTalkPath, destPath); 686 uniqueFilename = path.basename(outputPath); 687 } else { 688 fs.copyFileSync(dreamTalkPath, destPath); 689 } 690 691 // Determine MIME type from output extension (may be webp now) 692 const ext = path.extname(uniqueFilename).toLowerCase(); 693 const mimeTypes: Record<string, string> = { 694 '.jpg': 'image/jpeg', 695 '.jpeg': 'image/jpeg', 696 '.png': 'image/png', 697 '.gif': 'image/gif', 698 '.webp': 'image/webp', 699 '.svg': 'image/svg+xml', 700 '.mp4': 'video/mp4', 701 '.webm': 'video/webm', 702 '.mp3': 'audio/mpeg', 703 '.wav': 'audio/wav', 704 '.ogg': 'audio/ogg' 705 }; 706 const mimeType = mimeTypes[ext] || 'application/octet-stream'; 707 708 dreamTalkMedia = [{ 709 path: path.basename(dreamTalkPath), 710 type: mimeType, 711 data: `./media/${uniqueFilename}`, // Relative path instead of data URL 712 size: fs.statSync(path.join(mediaDir, uniqueFilename)).size 713 }]; 714 715 // Generate favicon from DreamTalk image 716 const faviconPath = path.join(buildDir, 'favicon.png'); 717 await this.generateFavicon(dreamTalkPath, faviconPath); 718 } 719 } 720 721 // Fallback: Generate favicon from first canvas image if no DreamTalk 722 const faviconPath = path.join(buildDir, 'favicon.png'); 723 if (!fs.existsSync(faviconPath)) { 724 // Find first image in blocks 725 for (const block of blocks) { 726 if (block.media?.src && block.media.type === 'image') { 727 const imgPath = block.media.src.startsWith('./media/') 728 ? path.join(buildDir, block.media.src) 729 : null; 730 if (imgPath && fs.existsSync(imgPath)) { 731 await this.generateFavicon(imgPath, faviconPath); 732 break; 733 } 734 } 735 } 736 } 737 738 // Build link resolver map 739 const linkResolver = await this.buildLinkResolver(dreamNodePath); 740 741 // Prepare data payload 742 const dreamsongData = { 743 dreamNodeName, 744 dreamNodeId, 745 dreamTalkMedia, 746 blocks, 747 linkResolver 748 }; 749 750 // Read pre-built viewer bundle from plugin root directory 751 if (!this.pluginDir) { 752 throw new Error('Plugin directory not set. GitHubService.setPluginDir() must be called during plugin initialization.'); 753 } 754 755 const viewerBundlePath = path.join(this.pluginDir, 'src/features/github-publishing/viewer-bundle', 'index.html'); 756 757 758 if (!fs.existsSync(viewerBundlePath)) { 759 throw new Error( 760 `Viewer bundle not found at ${viewerBundlePath}. Run "npm run plugin-build" to build the GitHub viewer bundle.` 761 ); 762 } 763 764 const template = fs.readFileSync(viewerBundlePath, 'utf-8'); 765 766 // Inject data into template 767 const html = template 768 .replace('{{DREAMNODE_NAME}}', dreamNodeName) 769 .replace('{{DREAMSONG_DATA}}', JSON.stringify(dreamsongData)); 770 771 // Write processed HTML 772 const indexPath = path.join(buildDir, 'index.html'); 773 fs.writeFileSync(indexPath, html); 774 775 // Copy assets directory (JS, CSS, images) 776 const viewerAssetsDir = path.join(this.pluginDir, 'src/features/github-publishing/viewer-bundle', 'assets'); 777 const buildAssetsDir = path.join(buildDir, 'assets'); 778 779 if (fs.existsSync(viewerAssetsDir)) { 780 // Create assets directory in build 781 fs.mkdirSync(buildAssetsDir, { recursive: true }); 782 783 // Copy all files from viewer assets to build assets 784 const assetFiles = fs.readdirSync(viewerAssetsDir); 785 for (const file of assetFiles) { 786 const srcPath = path.join(viewerAssetsDir, file); 787 const destPath = path.join(buildAssetsDir, file); 788 fs.copyFileSync(srcPath, destPath); 789 } 790 } else { 791 console.warn(`GitHubService: Assets directory not found at ${viewerAssetsDir}`); 792 } 793 794 795 // Deploy to gh-pages branch 796 await this.deployToPages(dreamNodePath, buildDir); 797 798 // Cleanup temp directory 799 try { 800 fs.rmSync(buildDir, { recursive: true, force: true }); 801 } catch (error) { 802 console.warn(`GitHubService: Failed to cleanup temp directory:`, error); 803 } 804 805 return buildDir; 806 } catch (error) { 807 if (error instanceof Error) { 808 throw new Error(`Failed to build static site: ${error.message}`); 809 } 810 throw error; 811 } 812 } 813 814 /** 815 * Build link resolver map for cross-DreamNode navigation 816 * 817 * Queries all submodules and extracts their hosting URLs from .udd files. 818 * This enables UUID-based navigation on GitHub Pages (click media → open source DreamNode). 819 */ 820 private async buildLinkResolver(dreamNodePath: string): Promise<any> { 821 const githubPagesUrls: Record<string, string> = {}; 822 const githubRepoUrls: Record<string, string> = {}; 823 const radicleIds: Record<string, string> = {}; 824 825 try { 826 // Get vault path (parent of dreamNodePath) 827 const vaultPath = path.dirname(dreamNodePath); 828 829 // Get all submodules for this DreamNode 830 const submodules = await this.getSubmodules(dreamNodePath, vaultPath); 831 832 for (const submodule of submodules) { 833 try { 834 // Read .udd file from submodule 835 const uddPath = path.join(submodule.path, '.udd'); 836 837 if (!fs.existsSync(uddPath)) { 838 console.warn(`GitHubService: .udd not found for submodule ${submodule.name} at ${uddPath}`); 839 continue; 840 } 841 842 const uddContent = fs.readFileSync(uddPath, 'utf-8'); 843 const udd = JSON.parse(uddContent); 844 845 if (!udd.uuid) { 846 console.warn(`GitHubService: UUID missing in .udd for submodule ${submodule.name}`); 847 continue; 848 } 849 850 // Map UUID to hosting URLs 851 if (udd.githubPagesUrl) { 852 githubPagesUrls[udd.uuid] = udd.githubPagesUrl; 853 } 854 855 if (udd.githubRepoUrl) { 856 githubRepoUrls[udd.uuid] = udd.githubRepoUrl; 857 } 858 859 if (udd.radicleId) { 860 radicleIds[udd.uuid] = udd.radicleId; 861 } 862 863 } catch (error) { 864 console.error(`GitHubService: Failed to read .udd for submodule ${submodule.name}:`, error); 865 // Continue with other submodules 866 } 867 } 868 869 870 return { 871 githubPagesUrls, 872 githubRepoUrls, 873 radicleIds 874 }; 875 876 } catch (error) { 877 console.error('GitHubService: Failed to build link resolver:', error); 878 // Return empty resolver on error (media will be non-clickable) 879 return { 880 githubPagesUrls: {}, 881 githubRepoUrls: {}, 882 radicleIds: {} 883 }; 884 } 885 } 886 887 /** 888 * Resolve source DreamNode UUID from submodule .udd file 889 * Mirrors the logic from media-resolver.ts but uses Node.js fs directly 890 */ 891 private async resolveSourceDreamNodeUuid( 892 filename: string, 893 vaultPath: string 894 ): Promise<string | null> { 895 try { 896 // Extract submodule directory from canvas path 897 // Canvas paths look like: "ArkCrystal/VectorEquilibrium/Vector Equilibrium.jpeg" 898 // We want the second segment: "VectorEquilibrium" 899 const submoduleMatch = filename.match(/^([^/]+)\/([^/]+)\//); 900 901 if (!submoduleMatch) { 902 // Local file (no submodule path) - not clickable 903 return null; 904 } 905 906 const submoduleDirName = submoduleMatch[2]; // "VectorEquilibrium" 907 const submodulePath = path.join(vaultPath, submoduleDirName); 908 909 // Check if .udd exists using UDDService 910 // If no .udd, this is just a regular subfolder (like "images/"), not a DreamNode submodule 911 if (!UDDService.uddExists(submodulePath)) { 912 // Not a DreamNode - just a regular folder, return silently 913 return null; 914 } 915 916 // Get UUID using UDDService 917 return await UDDService.getUUID(submodulePath); 918 919 } catch (error) { 920 console.error(`❌ [GitHubService] FAILED to resolve UUID from .udd file for ${filename}:`, error); 921 return null; 922 } 923 } 924 925 /** 926 * Deploy built site to GitHub Pages using local gh-pages branch 927 * Uses git worktree for incremental updates (only changed files are committed) 928 */ 929 private async deployToPages(dreamNodePath: string, buildDir: string): Promise<void> { 930 try { 931 // Check if gh-pages branch exists locally 932 let ghPagesExists = false; 933 try { 934 await execAsync(`git rev-parse --verify gh-pages`, { cwd: dreamNodePath }); 935 ghPagesExists = true; 936 } catch { 937 // Branch doesn't exist locally 938 } 939 940 // Check if gh-pages exists on remote (use 'github' remote name) 941 let remoteGhPagesExists = false; 942 try { 943 await execAsync(`git ls-remote --exit-code --heads github gh-pages`, { cwd: dreamNodePath }); 944 remoteGhPagesExists = true; 945 } catch { 946 // Remote branch doesn't exist 947 } 948 949 // Create worktree directory for gh-pages 950 const tmpOs = require('os'); 951 const worktreeDir = path.join(tmpOs.tmpdir(), `gh-pages-worktree-${Date.now()}`); 952 953 try { 954 if (!ghPagesExists && !remoteGhPagesExists) { 955 // First time: create orphan gh-pages branch 956 // Get current branch name BEFORE switching (git checkout - doesn't work for orphan branches) 957 const { stdout: currentBranch } = await execAsync( 958 `git rev-parse --abbrev-ref HEAD`, 959 { cwd: dreamNodePath } 960 ); 961 const branchToReturn = currentBranch.trim(); 962 963 await execAsync(`git checkout --orphan gh-pages`, { cwd: dreamNodePath }); 964 await execAsync(`git reset --hard`, { cwd: dreamNodePath }); 965 await execAsync(`git commit --allow-empty -m "Initialize gh-pages branch"`, { cwd: dreamNodePath }); 966 await execAsync(`git checkout "${branchToReturn}"`, { cwd: dreamNodePath }); // Return to original branch 967 } else if (!ghPagesExists && remoteGhPagesExists) { 968 // Remote exists but not local: fetch it 969 await execAsync(`git fetch github gh-pages:gh-pages`, { cwd: dreamNodePath }); 970 } 971 972 // Create worktree for gh-pages 973 await execAsync(`git worktree add "${worktreeDir}" gh-pages`, { cwd: dreamNodePath }); 974 975 // Clear existing files in worktree (except .git) 976 const existingFiles = fs.readdirSync(worktreeDir); 977 for (const file of existingFiles) { 978 if (file !== '.git') { 979 const filePath = path.join(worktreeDir, file); 980 fs.rmSync(filePath, { recursive: true, force: true }); 981 } 982 } 983 984 // Copy all files from buildDir to worktree 985 const copyRecursive = (src: string, dest: string) => { 986 const entries = fs.readdirSync(src, { withFileTypes: true }); 987 fs.mkdirSync(dest, { recursive: true }); 988 for (const entry of entries) { 989 const srcPath = path.join(src, entry.name); 990 const destPath = path.join(dest, entry.name); 991 if (entry.isDirectory()) { 992 copyRecursive(srcPath, destPath); 993 } else { 994 fs.copyFileSync(srcPath, destPath); 995 } 996 } 997 }; 998 999 const buildFiles = fs.readdirSync(buildDir, { withFileTypes: true }); 1000 for (const entry of buildFiles) { 1001 const srcPath = path.join(buildDir, entry.name); 1002 const destPath = path.join(worktreeDir, entry.name); 1003 if (entry.isDirectory()) { 1004 copyRecursive(srcPath, destPath); 1005 } else { 1006 fs.copyFileSync(srcPath, destPath); 1007 } 1008 } 1009 1010 // Stage all changes 1011 await execAsync(`git add -A`, { cwd: worktreeDir }); 1012 1013 // Check if there are any changes to commit 1014 const { stdout: statusOutput } = await execAsync( 1015 `git status --porcelain`, 1016 { cwd: worktreeDir } 1017 ); 1018 1019 if (statusOutput.trim()) { 1020 // Commit changes 1021 await execAsync( 1022 `git commit -m "Update DreamSong on GitHub Pages"`, 1023 { cwd: worktreeDir } 1024 ); 1025 1026 // Push to remote (use 'github' remote name) 1027 await execAsync( 1028 `git push github gh-pages`, 1029 { cwd: worktreeDir } 1030 ); 1031 } else { 1032 console.log('GitHubService: No changes to deploy to gh-pages'); 1033 } 1034 1035 } finally { 1036 // Cleanup: remove worktree 1037 try { 1038 await execAsync(`git worktree remove "${worktreeDir}" --force`, { cwd: dreamNodePath }); 1039 } catch { 1040 // Manual cleanup if worktree remove fails 1041 try { 1042 fs.rmSync(worktreeDir, { recursive: true, force: true }); 1043 await execAsync(`git worktree prune`, { cwd: dreamNodePath }); 1044 } catch { 1045 console.warn(`GitHubService: Failed to cleanup worktree at ${worktreeDir}`); 1046 } 1047 } 1048 } 1049 1050 } catch (error) { 1051 if (error instanceof Error) { 1052 throw new Error(`Failed to deploy to Pages: ${error.message}`); 1053 } 1054 throw error; 1055 } 1056 } 1057 1058 /** 1059 * Update .gitmodules file to replace local paths with GitHub URLs 1060 */ 1061 private async updateGitmodulesUrls( 1062 dreamNodePath: string, 1063 sharedSubmodules: Array<{ uuid: string; githubUrl: string; title: string; relativePath: string }> 1064 ): Promise<void> { 1065 const gitmodulesPath = path.join(dreamNodePath, '.gitmodules'); 1066 let content = fs.readFileSync(gitmodulesPath, 'utf-8'); 1067 1068 for (const submodule of sharedSubmodules) { 1069 // Ensure .git suffix 1070 const gitUrl = submodule.githubUrl.replace(/\.git$/, '') + '.git'; 1071 1072 // Build regex to find this specific submodule's URL line 1073 const escapedPath = submodule.relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1074 const urlRegex = new RegExp( 1075 `(\\[submodule "[^"]*"\\]\\s*path = ${escapedPath}\\s*url = )([^\\n]+)`, 1076 'gi' 1077 ); 1078 1079 // Replace URL only if it's not already a GitHub URL 1080 content = content.replace(urlRegex, (match, prefix, url) => { 1081 if (url.trim().startsWith('http') || url.trim().startsWith('git@')) { 1082 return match; // Already a remote URL 1083 } 1084 return `${prefix}${gitUrl}`; 1085 }); 1086 } 1087 1088 fs.writeFileSync(gitmodulesPath, content); 1089 } 1090 1091 /** 1092 * Share a single submodule recursively 1093 */ 1094 private async shareSubmodule( 1095 submodulePath: string, 1096 visitedUUIDs: Set<string> 1097 ): Promise<{ uuid: string; githubUrl: string; title: string; relativePath: string }> { 1098 1099 // Read submodule's .udd 1100 const udd = await this.readUDD(submodulePath); 1101 1102 // Check for circular dependencies 1103 if (visitedUUIDs.has(udd.uuid)) { 1104 if (!udd.githubRepoUrl) { 1105 throw new Error(`Circular dependency detected but node not yet shared: ${udd.title}`); 1106 } 1107 return { 1108 uuid: udd.uuid, 1109 githubUrl: udd.githubRepoUrl, 1110 title: udd.title, 1111 relativePath: path.basename(submodulePath) 1112 }; 1113 } 1114 1115 // Mark as visited 1116 visitedUUIDs.add(udd.uuid); 1117 1118 // Check if already shared - if so, skip repo creation but still rebuild Pages 1119 if (udd.githubRepoUrl) { 1120 1121 // Rebuild GitHub Pages with latest code (includes UUID resolution) 1122 try { 1123 const { parseCanvasToBlocks } = await import('../../dreamweaving/dreamsong/index'); 1124 const files = fs.readdirSync(submodulePath); 1125 const canvasFiles = files.filter(f => f.endsWith('.canvas')); 1126 let blocks: any[] = []; 1127 1128 if (canvasFiles.length > 0) { 1129 const canvasPath = path.join(submodulePath, canvasFiles[0]); 1130 const canvasContent = fs.readFileSync(canvasPath, 'utf-8'); 1131 const canvasData = JSON.parse(canvasContent); 1132 blocks = parseCanvasToBlocks(canvasData, udd.uuid); 1133 1134 const vaultPath = path.dirname(submodulePath); 1135 for (const block of blocks) { 1136 if (block.media && block.media.src && !block.media.src.startsWith('data:') && !block.media.src.startsWith('http')) { 1137 const resolvedUuid = await this.resolveSourceDreamNodeUuid(block.media.src, vaultPath); 1138 if (resolvedUuid) { 1139 block.media.sourceDreamNodeId = resolvedUuid; 1140 } 1141 // Store absolute path for file copy (NOT base64 embedding - avoids huge HTML files) 1142 const mediaPath = path.join(vaultPath, block.media.src); 1143 if (fs.existsSync(mediaPath)) { 1144 block.media._absolutePath = mediaPath; 1145 } 1146 } 1147 } 1148 } else if (udd.dreamTalk) { 1149 // DreamTalk-only nodes are handled by buildStaticSite via udd.dreamTalk 1150 blocks = []; 1151 } 1152 1153 await this.buildStaticSite(submodulePath, udd.uuid, udd.title, blocks); 1154 } catch (error) { 1155 console.warn(`GitHubService: Failed to rebuild Pages for ${udd.title}:`, error); 1156 } 1157 1158 return { 1159 uuid: udd.uuid, 1160 githubUrl: udd.githubRepoUrl, 1161 title: udd.title, 1162 relativePath: path.basename(submodulePath) 1163 }; 1164 } 1165 1166 // Recursively share this submodule (creates repo + builds Pages) 1167 const result = await this.shareDreamNode(submodulePath, udd.uuid, visitedUUIDs); 1168 1169 // Update submodule's .udd with GitHub URLs 1170 udd.githubRepoUrl = result.repoUrl; 1171 if (result.pagesUrl) { 1172 udd.githubPagesUrl = result.pagesUrl; 1173 } 1174 await this.writeUDD(submodulePath, udd); 1175 1176 // Commit .udd update in submodule 1177 try { 1178 await execAsync( 1179 'git add .udd && git commit -m "Add GitHub URLs to .udd" && git push github main || true', 1180 { cwd: submodulePath } 1181 ); 1182 } catch (error) { 1183 console.warn('GitHubService: Failed to commit .udd update in submodule:', error); 1184 } 1185 1186 return { 1187 uuid: udd.uuid, 1188 githubUrl: result.repoUrl, 1189 title: udd.title, 1190 relativePath: path.basename(submodulePath) 1191 }; 1192 } 1193 1194 /** 1195 * Unpublish a single submodule recursively 1196 */ 1197 private async unpublishSubmodule( 1198 submodulePath: string, 1199 vaultPath: string, 1200 visitedUUIDs: Set<string> 1201 ): Promise<{ uuid: string; title: string }> { 1202 // Read submodule's .udd 1203 const udd = await this.readUDD(submodulePath); 1204 1205 // Check for circular dependencies 1206 if (visitedUUIDs.has(udd.uuid)) { 1207 return { uuid: udd.uuid, title: udd.title }; 1208 } 1209 1210 // Mark as visited 1211 visitedUUIDs.add(udd.uuid); 1212 1213 // Check if this submodule is even published 1214 if (!udd.githubRepoUrl) { 1215 return { uuid: udd.uuid, title: udd.title }; 1216 } 1217 1218 // Recursively unpublish this submodule 1219 await this.unpublishDreamNode(submodulePath, udd.uuid, vaultPath, visitedUUIDs); 1220 1221 return { uuid: udd.uuid, title: udd.title }; 1222 } 1223 1224 /** 1225 * Unpublish DreamNode from GitHub (delete remote repo, clean metadata) 1226 */ 1227 async unpublishDreamNode( 1228 dreamNodePath: string, 1229 dreamNodeUuid: string, 1230 vaultPath: string, 1231 visitedUUIDs: Set<string> = new Set() 1232 ): Promise<void> { 1233 // Read .udd to get GitHub info 1234 const udd = await this.readUDD(dreamNodePath); 1235 1236 // Mark as visited 1237 visitedUUIDs.add(dreamNodeUuid); 1238 1239 // Check if published 1240 if (!udd.githubRepoUrl) { 1241 throw new Error(`DreamNode "${udd.title}" is not published to GitHub`); 1242 } 1243 1244 // Step 1: Unpublish all submodules recursively (depth-first) 1245 const submodules = await this.getSubmodules(dreamNodePath, vaultPath); 1246 1247 for (const submodule of submodules) { 1248 try { 1249 await this.unpublishSubmodule(submodule.path, vaultPath, visitedUUIDs); 1250 } catch (error) { 1251 console.error(`GitHubService: Failed to unpublish submodule ${submodule.name}:`, error); 1252 // Continue with other submodules 1253 } 1254 } 1255 1256 // Step 2: Delete gh-pages branch first (if exists) 1257 const repoMatch = udd.githubRepoUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 1258 if (repoMatch) { 1259 const [, owner, repo] = repoMatch; 1260 const cleanRepo = repo.replace(/\.git$/, ''); 1261 1262 try { 1263 const ghPath = await this.detectGhPath(); 1264 1265 // Try to delete gh-pages branch 1266 try { 1267 await execAsync(`"${ghPath}" api -X DELETE "repos/${owner}/${cleanRepo}/git/refs/heads/gh-pages"`); 1268 } catch { 1269 // Branch might not exist - that's okay 1270 } 1271 1272 // Delete GitHub repository 1273 await execAsync(`"${ghPath}" repo delete ${owner}/${cleanRepo} --yes`); 1274 } catch (error) { 1275 const errorMessage = error instanceof Error ? error.message : String(error); 1276 1277 // Check for missing delete_repo scope 1278 if (errorMessage.includes('delete_repo')) { 1279 throw new Error( 1280 'GitHub CLI needs "delete_repo" permission.\n\n' + 1281 'Run this command in your terminal:\n' + 1282 'gh auth refresh -h github.com -s delete_repo' 1283 ); 1284 } 1285 1286 // Check for already deleted 1287 if (errorMessage.includes('404') || errorMessage.includes('Not Found')) { 1288 // Continue - repo already deleted 1289 } else { 1290 console.warn(`GitHubService: Failed to delete GitHub repo:`, error); 1291 throw new Error(`Failed to delete repository: ${errorMessage}`); 1292 } 1293 } 1294 } 1295 1296 // Step 3: Remove github remote from local repo 1297 try { 1298 await execAsync('git remote remove github', { cwd: dreamNodePath }); 1299 } catch (error) { 1300 console.warn(`GitHubService: Failed to remove github remote (may not exist):`, error); 1301 // Continue - remote might not exist 1302 } 1303 1304 // Step 4: Clean .udd file 1305 delete udd.githubRepoUrl; 1306 delete udd.githubPagesUrl; 1307 await this.writeUDD(dreamNodePath, udd); 1308 1309 // Step 5: Commit .udd changes 1310 try { 1311 await execAsync( 1312 'git add .udd && git commit -m "Remove GitHub URLs from .udd" || true', 1313 { cwd: dreamNodePath } 1314 ); 1315 } catch (error) { 1316 console.warn(`GitHubService: Failed to commit .udd cleanup:`, error); 1317 } 1318 } 1319 1320 /** 1321 * Complete share workflow: create repo, enable Pages, update UDD 1322 */ 1323 async shareDreamNode( 1324 dreamNodePath: string, 1325 dreamNodeUuid: string, 1326 visitedUUIDs: Set<string> = new Set() 1327 ): Promise<GitHubShareResult> { 1328 // Read .udd to get title 1329 const udd = await this.readUDD(dreamNodePath); 1330 1331 // Mark this node as visited (prevent circular deps) 1332 visitedUUIDs.add(dreamNodeUuid); 1333 1334 // Check if already shared - .udd file has githubRepoUrl 1335 if (udd.githubRepoUrl) { 1336 1337 // Generate Obsidian URI 1338 const obsidianUri = this.generateObsidianURI(udd.githubRepoUrl); 1339 1340 return { 1341 repoUrl: udd.githubRepoUrl, 1342 pagesUrl: udd.githubPagesUrl, 1343 obsidianUri 1344 }; 1345 } 1346 1347 // Check if 'github' remote exists (edge case: previously shared but .udd not updated) 1348 try { 1349 const { stdout } = await execAsync('git remote get-url github', { cwd: dreamNodePath }); 1350 const existingGitHubUrl = stdout.trim(); 1351 1352 if (existingGitHubUrl) { 1353 // Update .udd with missing GitHub URL 1354 udd.githubRepoUrl = existingGitHubUrl; 1355 await this.writeUDD(dreamNodePath, udd); 1356 1357 // Build and deploy static site using unified pipeline 1358 let pagesUrl: string | undefined; 1359 try { 1360 const blocks = await this.prepareContentBlocks(dreamNodePath, dreamNodeUuid); 1361 await this.buildStaticSite(dreamNodePath, dreamNodeUuid, udd.title, blocks); 1362 1363 // Setup GitHub Pages (idempotent - will succeed even if already configured) 1364 try { 1365 pagesUrl = await this.setupPages(existingGitHubUrl); 1366 1367 // Update .udd with actual Pages URL 1368 udd.githubPagesUrl = pagesUrl; 1369 await this.writeUDD(dreamNodePath, udd); 1370 } catch (error) { 1371 console.warn('GitHubService: Failed to enable GitHub Pages:', error); 1372 // Fallback to predicted URL if API fails 1373 const match = existingGitHubUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 1374 if (match) { 1375 const [, owner, repo] = match; 1376 const cleanRepo = repo.replace(/\.git$/, ''); 1377 pagesUrl = `https://${owner}.github.io/${cleanRepo}`; 1378 udd.githubPagesUrl = pagesUrl; 1379 await this.writeUDD(dreamNodePath, udd); 1380 } 1381 } 1382 } catch (error) { 1383 console.warn(`GitHubService: Failed to build static site (will continue without Pages):`, error); 1384 // Fallback to predicted URL 1385 const match = existingGitHubUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 1386 if (match) { 1387 const [, owner, repo] = match; 1388 const cleanRepo = repo.replace(/\.git$/, ''); 1389 pagesUrl = `https://${owner}.github.io/${cleanRepo}`; 1390 } 1391 } 1392 1393 // Generate Obsidian URI 1394 const obsidianUri = this.generateObsidianURI(existingGitHubUrl); 1395 1396 return { 1397 repoUrl: existingGitHubUrl, 1398 pagesUrl, 1399 obsidianUri 1400 }; 1401 } 1402 } catch { 1403 // No 'github' remote exists - this is fine, continue with normal sharing 1404 } 1405 1406 // Step 1: Discover and share all submodules recursively (depth-first) 1407 const submodules = await this.getSubmodules(dreamNodePath); 1408 const sharedSubmodules: Array<{ uuid: string; githubUrl: string; title: string; relativePath: string }> = []; 1409 1410 1411 for (const submodule of submodules) { 1412 try { 1413 const result = await this.shareSubmodule(submodule.path, visitedUUIDs); 1414 sharedSubmodules.push(result); 1415 } catch (error) { 1416 console.error(`GitHubService: Failed to share submodule ${submodule.name}:`, error); 1417 // Continue with other submodules 1418 } 1419 } 1420 1421 // Step 2: Update .gitmodules with GitHub URLs 1422 if (sharedSubmodules.length > 0) { 1423 await this.updateGitmodulesUrls(dreamNodePath, sharedSubmodules); 1424 1425 // Commit .gitmodules changes 1426 try { 1427 await execAsync( 1428 'git add .gitmodules && git commit -m "Update submodule URLs for GitHub" || true', 1429 { cwd: dreamNodePath } 1430 ); 1431 } catch (error) { 1432 console.warn('GitHubService: Failed to commit .gitmodules update:', error); 1433 } 1434 } 1435 1436 // Step 3: Find available repo name based on title 1437 const repoName = await this.findAvailableRepoName(udd.title); 1438 1439 // Step 4: Create GitHub repository 1440 const repoUrl = await this.createRepo(dreamNodePath, repoName); 1441 1442 // Step 5: Build and deploy static DreamSong site using unified pipeline 1443 let pagesUrl: string | undefined; 1444 try { 1445 const blocks = await this.prepareContentBlocks(dreamNodePath, dreamNodeUuid); 1446 await this.buildStaticSite(dreamNodePath, dreamNodeUuid, udd.title, blocks); 1447 1448 // Step 6: Setup GitHub Pages (configure to serve from gh-pages branch) 1449 // IMPORTANT: This must happen AFTER buildStaticSite pushes the gh-pages branch 1450 try { 1451 pagesUrl = await this.setupPages(repoUrl); 1452 } catch (error) { 1453 console.warn('GitHubService: Failed to enable GitHub Pages:', error); 1454 // Continue without Pages URL - site is deployed but Pages config might fail 1455 } 1456 1457 } catch (error) { 1458 console.warn(`GitHubService: Failed to build static site (will continue without Pages):`, error); 1459 // Non-fatal - repo is created, just no Pages hosting 1460 } 1461 1462 // Step 7: Generate Obsidian URI 1463 const obsidianUri = this.generateObsidianURI(repoUrl); 1464 1465 return { 1466 repoUrl, 1467 pagesUrl, 1468 obsidianUri 1469 }; 1470 } 1471 } 1472 1473 // Singleton instance 1474 export const githubService = new GitHubService();