GitHubService.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 '../../utils/title-sanitization'; 18 19 const execAsync = promisify(exec); 20 21 export interface GitHubShareResult { 22 /** GitHub repository URL */ 23 repoUrl: string; 24 25 /** GitHub Pages URL (if Pages enabled) */ 26 pagesUrl?: string; 27 28 /** Obsidian URI for one-click cloning */ 29 obsidianUri: string; 30 } 31 32 interface SubmoduleInfo { 33 name: string; 34 path: string; // Full path to actual git repo (from url field) 35 relativePath: string; // Relative path within parent (from path field) 36 url: string; // URL from .gitmodules 37 } 38 39 export class GitHubService { 40 private ghPath: string | null = null; 41 private pluginDir: string | null = null; 42 43 /** 44 * Set the plugin directory path (must be called during plugin initialization) 45 */ 46 setPluginDir(dir: string): void { 47 this.pluginDir = dir; 48 console.log(`GitHubService: Plugin directory set to ${dir}`); 49 } 50 51 /** 52 * Detect and cache the GitHub CLI path 53 */ 54 private async detectGhPath(): Promise<string> { 55 if (this.ghPath) { 56 return this.ghPath; 57 } 58 59 // Try with full path first (Homebrew default on Apple Silicon) 60 const pathsToTry = [ 61 '/opt/homebrew/bin/gh', 62 '/usr/local/bin/gh', 63 'gh' 64 ]; 65 66 for (const path of pathsToTry) { 67 try { 68 await execAsync(`${path} --version`); 69 this.ghPath = path; 70 console.log(`GitHubService: Found gh at ${path}`); 71 return path; 72 } catch { 73 // Try next path 74 } 75 } 76 77 throw new Error('GitHub CLI not found in any standard location'); 78 } 79 80 /** 81 * Sanitize DreamNode title for GitHub repository name 82 * Uses unified PascalCase sanitization for consistency with file system 83 */ 84 private sanitizeRepoName(title: string): string { 85 return sanitizeTitleToPascalCase(title); 86 } 87 88 /** 89 * Check if a GitHub repository exists 90 */ 91 private async repoExists(repoName: string): Promise<boolean> { 92 try { 93 const ghPath = await this.detectGhPath(); 94 // Try to view repo (fails if doesn't exist) 95 // Note: cwd doesn't matter for GitHub API calls 96 await execAsync(`"${ghPath}" repo view ${repoName}`); 97 return true; 98 } catch { 99 return false; 100 } 101 } 102 103 /** 104 * Find an available repository name, adding -2, -3, etc. if needed 105 */ 106 private async findAvailableRepoName(title: string): Promise<string> { 107 let repoName = this.sanitizeRepoName(title); 108 109 // Handle edge case: sanitized name is empty 110 if (!repoName) { 111 repoName = 'dreamnode'; 112 } 113 114 // Check if base name is available 115 if (!(await this.repoExists(repoName))) { 116 return repoName; 117 } 118 119 // Try numbered variants (-2, -3, etc.) 120 let attempt = 2; 121 while (attempt < 100) { // Safety limit 122 const numberedName = `${repoName}-${attempt}`; 123 if (!(await this.repoExists(numberedName))) { 124 return numberedName; 125 } 126 attempt++; 127 } 128 129 throw new Error(`Could not find available name for "${title}" after 100 attempts`); 130 } 131 132 /** 133 * Read .udd file from DreamNode 134 */ 135 private async readUDD(dreamNodePath: string): Promise<any> { 136 const uddPath = path.join(dreamNodePath, '.udd'); 137 const content = fs.readFileSync(uddPath, 'utf-8'); 138 return JSON.parse(content); 139 } 140 141 /** 142 * Write .udd file to DreamNode 143 */ 144 private async writeUDD(dreamNodePath: string, udd: any): Promise<void> { 145 const uddPath = path.join(dreamNodePath, '.udd'); 146 fs.writeFileSync(uddPath, JSON.stringify(udd, null, 2), 'utf-8'); 147 } 148 149 /** 150 * Get list of submodules from .gitmodules file 151 * Resolves GitHub URLs to local vault paths automatically 152 */ 153 async getSubmodules(dreamNodePath: string, vaultPath?: string): Promise<SubmoduleInfo[]> { 154 const gitmodulesPath = path.join(dreamNodePath, '.gitmodules'); 155 156 if (!fs.existsSync(gitmodulesPath)) { 157 return []; 158 } 159 160 const content = fs.readFileSync(gitmodulesPath, 'utf-8'); 161 const submodules: SubmoduleInfo[] = []; 162 163 // If vaultPath not provided, derive it from dreamNodePath 164 if (!vaultPath) { 165 vaultPath = path.dirname(dreamNodePath); 166 } 167 168 // Parse .gitmodules format 169 const lines = content.split('\n'); 170 let currentSubmodule: Partial<SubmoduleInfo> = {}; 171 172 for (const line of lines) { 173 const submoduleMatch = line.match(/\[submodule "([^"]+)"\]/); 174 if (submoduleMatch) { 175 if (currentSubmodule.name && currentSubmodule.url) { 176 submodules.push(currentSubmodule as SubmoduleInfo); 177 } 178 currentSubmodule = { name: submoduleMatch[1] }; 179 continue; 180 } 181 182 const pathMatch = line.match(/path = (.+)/); 183 if (pathMatch && currentSubmodule.name) { 184 currentSubmodule.relativePath = pathMatch[1].trim(); 185 } 186 187 const urlMatch = line.match(/url = (.+)/); 188 if (urlMatch && currentSubmodule.name && currentSubmodule.relativePath) { 189 const url = urlMatch[1].trim(); 190 currentSubmodule.url = url; 191 192 // Resolve URL to local path 193 if (url.startsWith('http') || url.startsWith('git@')) { 194 // GitHub/remote URL - use relativePath to find standalone repo 195 // The relativePath tells us the submodule directory name (e.g., "Thunderstorm-Generator-UPDATED-...") 196 // The standalone repo should have the same name in the vault root 197 const localPath = path.join(vaultPath, currentSubmodule.relativePath); 198 199 if (fs.existsSync(localPath)) { 200 currentSubmodule.path = localPath; 201 } else { 202 console.warn(`GitHubService: Local repo not found for ${url}: ${localPath}`); 203 currentSubmodule.path = url; // Fallback to URL 204 } 205 } else { 206 // Local path - use directly 207 currentSubmodule.path = url; 208 } 209 } 210 } 211 212 if (currentSubmodule.name && currentSubmodule.url) { 213 submodules.push(currentSubmodule as SubmoduleInfo); 214 } 215 216 return submodules; 217 } 218 219 /** 220 * Check if GitHub CLI is available and authenticated 221 */ 222 async isAvailable(): Promise<{ available: boolean; error?: string }> { 223 try { 224 const ghPath = await this.detectGhPath(); 225 226 // Check if authenticated (stderr goes to stdout for gh auth status) 227 const { stdout, stderr } = await execAsync(`${ghPath} auth status 2>&1`); 228 const output = stdout + stderr; 229 230 console.log('GitHubService: gh auth status output:', output); 231 232 if (output.includes('Logged in to github.com')) { 233 return { available: true }; 234 } 235 236 return { 237 available: false, 238 error: 'GitHub CLI not authenticated. Run: gh auth login' 239 }; 240 } catch (error) { 241 const errorMessage = error instanceof Error ? error.message : String(error); 242 console.error('GitHubService: isAvailable check failed:', errorMessage); 243 244 if (errorMessage.includes('command not found') || errorMessage.includes('ENOENT') || errorMessage.includes('not found in any standard location')) { 245 return { 246 available: false, 247 error: 'GitHub CLI not found. Install from: https://cli.github.com or ensure /opt/homebrew/bin is in PATH' 248 }; 249 } 250 251 return { 252 available: false, 253 error: `GitHub CLI error: ${errorMessage}` 254 }; 255 } 256 } 257 258 /** 259 * Create public GitHub repository and push DreamNode content 260 */ 261 async createRepo(dreamNodePath: string, repoName: string): Promise<string> { 262 // Verify directory exists 263 if (!fs.existsSync(dreamNodePath)) { 264 throw new Error(`DreamNode path does not exist: ${dreamNodePath}`); 265 } 266 267 // Ensure it's a git repo 268 const gitDir = path.join(dreamNodePath, '.git'); 269 if (!fs.existsSync(gitDir)) { 270 throw new Error('DreamNode is not a git repository. Cannot share to GitHub.'); 271 } 272 273 try { 274 const ghPath = await this.detectGhPath(); 275 276 // Create public GitHub repository with provided name 277 const { stdout } = await execAsync( 278 `"${ghPath}" repo create ${repoName} --public --source="${dreamNodePath}" --remote=github --push`, 279 { cwd: dreamNodePath } 280 ); 281 282 // Extract repository URL from output 283 const match = stdout.match(/https:\/\/github\.com\/[^\s]+/); 284 if (!match) { 285 throw new Error('Failed to extract repository URL from gh output'); 286 } 287 288 const repoUrl = match[0]; 289 return repoUrl; 290 } catch (error) { 291 if (error instanceof Error) { 292 throw new Error(`Failed to create GitHub repository: ${error.message}`); 293 } 294 throw error; 295 } 296 } 297 298 /** 299 * Enable GitHub Pages for repository (serves from gh-pages branch) 300 */ 301 async setupPages(repoUrl: string): Promise<string> { 302 // Extract owner/repo from URL 303 const match = repoUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 304 if (!match) { 305 throw new Error(`Invalid GitHub URL: ${repoUrl}`); 306 } 307 308 const [, owner, repo] = match; 309 const cleanRepo = repo.replace(/\.git$/, ''); 310 const pagesUrl = `https://${owner}.github.io/${cleanRepo}`; 311 312 try { 313 const ghPath = await this.detectGhPath(); 314 315 // Enable GitHub Pages via API - serve from gh-pages branch 316 // Note: gh CLI doesn't have native pages command, so we use gh api 317 await execAsync( 318 `"${ghPath}" api -X POST "repos/${owner}/${cleanRepo}/pages" -f source[branch]=gh-pages -f source[path]=/` 319 ); 320 321 console.log(`GitHubService: GitHub Pages enabled successfully`); 322 return pagesUrl; 323 } catch (error) { 324 if (error instanceof Error) { 325 const errorMsg = error.message.toLowerCase(); 326 327 // Pages already exists - this is fine, return the URL 328 if (errorMsg.includes('already exists') || 329 errorMsg.includes('409') || 330 errorMsg.includes('unexpected end of json')) { 331 console.log(`GitHubService: GitHub Pages already configured (this is expected)`); 332 return pagesUrl; 333 } 334 335 // Other error - log but don't throw (non-fatal) 336 console.warn(`GitHubService: Could not configure Pages API (site will still work):`, error.message); 337 return pagesUrl; 338 } 339 throw error; 340 } 341 } 342 343 /** 344 * Generate Obsidian URI for cloning from GitHub 345 */ 346 generateObsidianURI(repoUrl: string): string { 347 // Extract repo path (e.g., "user/dreamnode-uuid") 348 const match = repoUrl.match(/github\.com\/([^/]+\/[^/\s]+)/); 349 if (!match) { 350 throw new Error(`Invalid GitHub URL: ${repoUrl}`); 351 } 352 353 const repoPath = match[1].replace(/\.git$/, ''); 354 355 return `obsidian://interbrain-clone?repo=github.com/${repoPath}`; 356 } 357 358 /** 359 * Clone DreamNode from GitHub URL 360 */ 361 async clone(githubUrl: string, destinationPath: string): Promise<void> { 362 try { 363 // Ensure parent directory exists 364 const parentDir = path.dirname(destinationPath); 365 if (!fs.existsSync(parentDir)) { 366 fs.mkdirSync(parentDir, { recursive: true }); 367 } 368 369 // Clone only the main branch (avoid gh-pages branch used for GitHub Pages) 370 // --single-branch ensures we only get main, not all branches 371 // -b main explicitly checks out main branch 372 await execAsync(`git clone --single-branch -b main "${githubUrl}" "${destinationPath}"`); 373 } catch (error) { 374 if (error instanceof Error) { 375 throw new Error(`Failed to clone from GitHub: ${error.message}`); 376 } 377 throw error; 378 } 379 } 380 381 /** 382 * Build static DreamSong site for GitHub Pages 383 * @param blocks - Pre-computed DreamSongBlocks from local rendering (already has media resolved) 384 */ 385 async buildStaticSite( 386 dreamNodePath: string, 387 dreamNodeId: string, 388 dreamNodeName: string, 389 blocks: any[] // DreamSongBlock[] from local cache 390 ): Promise<string> { 391 try { 392 // Read .udd file to get metadata 393 const uddPath = path.join(dreamNodePath, '.udd'); 394 if (!fs.existsSync(uddPath)) { 395 throw new Error('.udd file not found'); 396 } 397 398 const uddContent = fs.readFileSync(uddPath, 'utf-8'); 399 const udd = JSON.parse(uddContent); 400 401 // Get DreamTalk media if exists 402 const dreamTalkMedia = udd.dreamTalk 403 ? await this.loadDreamTalkMedia(path.join(dreamNodePath, udd.dreamTalk)) 404 : undefined; 405 406 // Build link resolver map (will be populated when we integrate with DreamNodeService) 407 const linkResolver = await this.buildLinkResolver(dreamNodePath); 408 409 // Prepare data payload 410 const dreamsongData = { 411 dreamNodeName, 412 dreamNodeId, 413 dreamTalkMedia, 414 blocks, 415 linkResolver 416 }; 417 418 // Read pre-built viewer bundle from plugin root directory 419 if (!this.pluginDir) { 420 throw new Error('Plugin directory not set. GitHubService.setPluginDir() must be called during plugin initialization.'); 421 } 422 423 const viewerBundlePath = path.join(this.pluginDir, 'viewer-bundle', 'index.html'); 424 425 console.log(`GitHubService: Looking for viewer bundle at ${viewerBundlePath}`); 426 427 if (!fs.existsSync(viewerBundlePath)) { 428 throw new Error( 429 `Viewer bundle not found at ${viewerBundlePath}. Run "npm run plugin-build" to build the GitHub viewer bundle.` 430 ); 431 } 432 433 const template = fs.readFileSync(viewerBundlePath, 'utf-8'); 434 435 // Inject data into template 436 const html = template 437 .replace('{{DREAMNODE_NAME}}', dreamNodeName) 438 .replace('{{DREAMSONG_DATA}}', JSON.stringify(dreamsongData)); 439 440 // Create output directory in system temp (outside the repo) 441 const tmpOs = require('os'); 442 const buildDir = path.join(tmpOs.tmpdir(), `dreamsong-build-${dreamNodeId}-${Date.now()}`); 443 if (!fs.existsSync(buildDir)) { 444 fs.mkdirSync(buildDir, { recursive: true }); 445 } 446 447 console.log(`GitHubService: Preparing static site at ${buildDir}`); 448 449 // Write processed HTML 450 const indexPath = path.join(buildDir, 'index.html'); 451 fs.writeFileSync(indexPath, html); 452 453 // Copy assets directory (JS, CSS, images) 454 const viewerAssetsDir = path.join(this.pluginDir, 'viewer-bundle', 'assets'); 455 const buildAssetsDir = path.join(buildDir, 'assets'); 456 457 if (fs.existsSync(viewerAssetsDir)) { 458 // Create assets directory in build 459 fs.mkdirSync(buildAssetsDir, { recursive: true }); 460 461 // Copy all files from viewer assets to build assets 462 const assetFiles = fs.readdirSync(viewerAssetsDir); 463 for (const file of assetFiles) { 464 const srcPath = path.join(viewerAssetsDir, file); 465 const destPath = path.join(buildAssetsDir, file); 466 fs.copyFileSync(srcPath, destPath); 467 } 468 console.log(`GitHubService: Copied ${assetFiles.length} asset files`); 469 } else { 470 console.warn(`GitHubService: Assets directory not found at ${viewerAssetsDir}`); 471 } 472 473 console.log(`GitHubService: Static site ready for deployment`); 474 475 // Deploy to gh-pages branch 476 await this.deployToPages(dreamNodePath, buildDir); 477 478 // Cleanup temp directory 479 try { 480 fs.rmSync(buildDir, { recursive: true, force: true }); 481 console.log(`GitHubService: Cleaned up temp build directory`); 482 } catch (error) { 483 console.warn(`GitHubService: Failed to cleanup temp directory:`, error); 484 } 485 486 return buildDir; 487 } catch (error) { 488 if (error instanceof Error) { 489 throw new Error(`Failed to build static site: ${error.message}`); 490 } 491 throw error; 492 } 493 } 494 495 /** 496 * Load DreamTalk media file as data URL 497 */ 498 private async loadDreamTalkMedia(mediaPath: string): Promise<any> { 499 if (!fs.existsSync(mediaPath)) { 500 return undefined; 501 } 502 503 // Read file and convert to data URL 504 const buffer = fs.readFileSync(mediaPath); 505 const base64 = buffer.toString('base64'); 506 507 // Determine MIME type from extension 508 const ext = path.extname(mediaPath).toLowerCase(); 509 const mimeTypes: Record<string, string> = { 510 '.jpg': 'image/jpeg', 511 '.jpeg': 'image/jpeg', 512 '.png': 'image/png', 513 '.gif': 'image/gif', 514 '.webp': 'image/webp', 515 '.mp4': 'video/mp4', 516 '.webm': 'video/webm', 517 '.mp3': 'audio/mpeg', 518 '.wav': 'audio/wav', 519 '.ogg': 'audio/ogg' 520 }; 521 522 const mimeType = mimeTypes[ext] || 'application/octet-stream'; 523 524 return [{ 525 path: path.basename(mediaPath), 526 absolutePath: mediaPath, 527 type: mimeType, 528 data: `data:${mimeType};base64,${base64}`, 529 size: buffer.length 530 }]; 531 } 532 533 /** 534 * Build link resolver map for cross-DreamNode navigation 535 * 536 * Queries all submodules and extracts their hosting URLs from .udd files. 537 * This enables UUID-based navigation on GitHub Pages (click media → open source DreamNode). 538 */ 539 private async buildLinkResolver(dreamNodePath: string): Promise<any> { 540 const githubPagesUrls: Record<string, string> = {}; 541 const githubRepoUrls: Record<string, string> = {}; 542 const radicleIds: Record<string, string> = {}; 543 544 try { 545 // Get vault path (parent of dreamNodePath) 546 const vaultPath = path.dirname(dreamNodePath); 547 548 // Get all submodules for this DreamNode 549 const submodules = await this.getSubmodules(dreamNodePath, vaultPath); 550 console.log(`GitHubService: Building link resolver for ${submodules.length} submodule(s)`); 551 552 for (const submodule of submodules) { 553 try { 554 // Read .udd file from submodule 555 const uddPath = path.join(submodule.path, '.udd'); 556 557 if (!fs.existsSync(uddPath)) { 558 console.warn(`GitHubService: .udd not found for submodule ${submodule.name} at ${uddPath}`); 559 continue; 560 } 561 562 const uddContent = fs.readFileSync(uddPath, 'utf-8'); 563 const udd = JSON.parse(uddContent); 564 565 if (!udd.uuid) { 566 console.warn(`GitHubService: UUID missing in .udd for submodule ${submodule.name}`); 567 continue; 568 } 569 570 // Map UUID to hosting URLs 571 if (udd.githubPagesUrl) { 572 githubPagesUrls[udd.uuid] = udd.githubPagesUrl; 573 console.log(`GitHubService: Mapped ${submodule.name} (${udd.uuid}) → Pages: ${udd.githubPagesUrl}`); 574 } 575 576 if (udd.githubRepoUrl) { 577 githubRepoUrls[udd.uuid] = udd.githubRepoUrl; 578 console.log(`GitHubService: Mapped ${submodule.name} (${udd.uuid}) → Repo: ${udd.githubRepoUrl}`); 579 } 580 581 if (udd.radicleId) { 582 radicleIds[udd.uuid] = udd.radicleId; 583 console.log(`GitHubService: Mapped ${submodule.name} (${udd.uuid}) → Radicle: ${udd.radicleId}`); 584 } 585 586 } catch (error) { 587 console.error(`GitHubService: Failed to read .udd for submodule ${submodule.name}:`, error); 588 // Continue with other submodules 589 } 590 } 591 592 console.log(`GitHubService: Link resolver built - ${Object.keys(githubPagesUrls).length} Pages URLs, ${Object.keys(githubRepoUrls).length} Repo URLs, ${Object.keys(radicleIds).length} Radicle IDs`); 593 594 return { 595 githubPagesUrls, 596 githubRepoUrls, 597 radicleIds 598 }; 599 600 } catch (error) { 601 console.error('GitHubService: Failed to build link resolver:', error); 602 // Return empty resolver on error (media will be non-clickable) 603 return { 604 githubPagesUrls: {}, 605 githubRepoUrls: {}, 606 radicleIds: {} 607 }; 608 } 609 } 610 611 /** 612 * Resolve source DreamNode UUID from submodule .udd file 613 * Mirrors the logic from media-resolver.ts but uses Node.js fs directly 614 */ 615 private async resolveSourceDreamNodeUuid( 616 filename: string, 617 vaultPath: string 618 ): Promise<string | null> { 619 try { 620 // Extract submodule directory from canvas path 621 // Canvas paths look like: "ArkCrystal/VectorEquilibrium/Vector Equilibrium.jpeg" 622 // We want the second segment: "VectorEquilibrium" 623 const submoduleMatch = filename.match(/^([^/]+)\/([^/]+)\//); 624 625 if (!submoduleMatch) { 626 // Local file (no submodule path) - not clickable 627 console.log(`[GitHubService] Local file (non-clickable): ${filename}`); 628 return null; 629 } 630 631 const submoduleDirName = submoduleMatch[2]; // "VectorEquilibrium" 632 console.log(`[GitHubService] Detected submodule media: ${filename} → directory: ${submoduleDirName}`); 633 634 // Read .udd file from submodule directory 635 const uddPath = path.join(vaultPath, submoduleDirName, '.udd'); 636 637 if (!fs.existsSync(uddPath)) { 638 // CRITICAL ERROR: Missing .udd file 639 console.error(`❌ [GitHubService] CORRUPTED DREAMNODE: ${submoduleDirName} is missing .udd metadata file`); 640 console.error(` Expected .udd at: ${uddPath}`); 641 console.error(` Media will be non-clickable until .udd is restored`); 642 return null; 643 } 644 645 // Parse .udd file to extract UUID 646 const uddContent = fs.readFileSync(uddPath, 'utf-8'); 647 const udd = JSON.parse(uddContent); 648 649 if (!udd.uuid) { 650 console.error(`❌ [GitHubService] CORRUPTED DREAMNODE: ${submoduleDirName} .udd file is missing UUID field`); 651 return null; 652 } 653 654 console.log(`✅ [GitHubService] Resolved UUID for ${submoduleDirName}: ${udd.uuid}`); 655 return udd.uuid; 656 657 } catch (error) { 658 console.error(`❌ [GitHubService] FAILED to resolve UUID from .udd file for ${filename}:`, error); 659 return null; 660 } 661 } 662 663 /** 664 * Deploy built site to GitHub Pages using gh-pages branch strategy 665 * Creates an orphan branch with only the built HTML (no source files) 666 */ 667 private async deployToPages(dreamNodePath: string, buildDir: string): Promise<void> { 668 try { 669 // Step 1: Initialize a fresh git repo in temp directory 670 await execAsync(`git init`, { cwd: buildDir }); 671 672 // Step 2: Add all built files 673 await execAsync(`git add .`, { cwd: buildDir }); 674 675 // Step 3: Commit built site 676 await execAsync( 677 `git commit -m "Deploy DreamSong to GitHub Pages"`, 678 { cwd: buildDir } 679 ); 680 681 // Step 4: Get the remote URL from main repo 682 const { stdout: remoteUrl } = await execAsync( 683 `git remote get-url github`, 684 { cwd: dreamNodePath } 685 ); 686 687 // Step 5: Add remote to build repo 688 await execAsync( 689 `git remote add origin ${remoteUrl.trim()}`, 690 { cwd: buildDir } 691 ); 692 693 // Step 6: Force push to gh-pages branch (orphan branch) 694 await execAsync( 695 `git push -f origin HEAD:gh-pages`, 696 { cwd: buildDir } 697 ); 698 699 console.log('GitHubService: Successfully deployed to gh-pages branch'); 700 701 } catch (error) { 702 if (error instanceof Error) { 703 throw new Error(`Failed to deploy to Pages: ${error.message}`); 704 } 705 throw error; 706 } 707 } 708 709 /** 710 * Update .gitmodules file to replace local paths with GitHub URLs 711 */ 712 private async updateGitmodulesUrls( 713 dreamNodePath: string, 714 sharedSubmodules: Array<{ uuid: string; githubUrl: string; title: string; relativePath: string }> 715 ): Promise<void> { 716 const gitmodulesPath = path.join(dreamNodePath, '.gitmodules'); 717 let content = fs.readFileSync(gitmodulesPath, 'utf-8'); 718 719 for (const submodule of sharedSubmodules) { 720 // Ensure .git suffix 721 const gitUrl = submodule.githubUrl.replace(/\.git$/, '') + '.git'; 722 723 // Build regex to find this specific submodule's URL line 724 const escapedPath = submodule.relativePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 725 const urlRegex = new RegExp( 726 `(\\[submodule "[^"]*"\\]\\s*path = ${escapedPath}\\s*url = )([^\\n]+)`, 727 'gi' 728 ); 729 730 // Replace URL only if it's not already a GitHub URL 731 content = content.replace(urlRegex, (match, prefix, url) => { 732 if (url.trim().startsWith('http') || url.trim().startsWith('git@')) { 733 return match; // Already a remote URL 734 } 735 return `${prefix}${gitUrl}`; 736 }); 737 } 738 739 fs.writeFileSync(gitmodulesPath, content); 740 } 741 742 /** 743 * Share a single submodule recursively 744 */ 745 private async shareSubmodule( 746 submodulePath: string, 747 visitedUUIDs: Set<string> 748 ): Promise<{ uuid: string; githubUrl: string; title: string; relativePath: string }> { 749 750 // Read submodule's .udd 751 const udd = await this.readUDD(submodulePath); 752 753 // Check for circular dependencies 754 if (visitedUUIDs.has(udd.uuid)) { 755 console.log(`GitHubService: Skipping circular dependency: ${udd.title}`); 756 if (!udd.githubRepoUrl) { 757 throw new Error(`Circular dependency detected but node not yet shared: ${udd.title}`); 758 } 759 return { 760 uuid: udd.uuid, 761 githubUrl: udd.githubRepoUrl, 762 title: udd.title, 763 relativePath: path.basename(submodulePath) 764 }; 765 } 766 767 // Mark as visited 768 visitedUUIDs.add(udd.uuid); 769 770 // Check if already shared - if so, skip repo creation but still rebuild Pages 771 if (udd.githubRepoUrl) { 772 console.log(`GitHubService: Submodule already shared, rebuilding GitHub Pages: ${udd.title}`); 773 774 // Rebuild GitHub Pages with latest code (includes UUID resolution) 775 try { 776 const { parseCanvasToBlocks, getMimeType } = await import('../../services/dreamsong'); 777 const files = fs.readdirSync(submodulePath); 778 const canvasFiles = files.filter(f => f.endsWith('.canvas')); 779 let blocks: any[] = []; 780 781 if (canvasFiles.length > 0) { 782 const canvasPath = path.join(submodulePath, canvasFiles[0]); 783 const canvasContent = fs.readFileSync(canvasPath, 'utf-8'); 784 const canvasData = JSON.parse(canvasContent); 785 blocks = parseCanvasToBlocks(canvasData, udd.uuid); 786 787 const vaultPath = path.dirname(submodulePath); 788 for (const block of blocks) { 789 if (block.media && block.media.src && !block.media.src.startsWith('data:') && !block.media.src.startsWith('http')) { 790 const resolvedUuid = await this.resolveSourceDreamNodeUuid(block.media.src, vaultPath); 791 if (resolvedUuid) { 792 block.media.sourceDreamNodeId = resolvedUuid; 793 } 794 const mediaPath = path.join(vaultPath, block.media.src); 795 if (fs.existsSync(mediaPath)) { 796 const buffer = fs.readFileSync(mediaPath); 797 const base64 = buffer.toString('base64'); 798 const mimeType = getMimeType(block.media.src); 799 block.media.src = `data:${mimeType};base64,${base64}`; 800 } 801 } 802 } 803 } else if (udd.dreamTalk) { 804 const dreamTalkPath = path.join(submodulePath, udd.dreamTalk); 805 if (fs.existsSync(dreamTalkPath)) { 806 const buffer = fs.readFileSync(dreamTalkPath); 807 const base64 = buffer.toString('base64'); 808 const mimeType = getMimeType(udd.dreamTalk); 809 blocks = [{ 810 id: 'dreamtalk-fallback', 811 type: 'media', 812 media: { src: `data:${mimeType};base64,${base64}`, type: mimeType.startsWith('video/') ? 'video' : 'image', alt: udd.title }, 813 text: '', 814 edges: [] 815 }]; 816 } 817 } 818 819 await this.buildStaticSite(submodulePath, udd.uuid, udd.title, blocks); 820 console.log(`GitHubService: Rebuilt GitHub Pages for ${udd.title}`); 821 } catch (error) { 822 console.warn(`GitHubService: Failed to rebuild Pages for ${udd.title}:`, error); 823 } 824 825 return { 826 uuid: udd.uuid, 827 githubUrl: udd.githubRepoUrl, 828 title: udd.title, 829 relativePath: path.basename(submodulePath) 830 }; 831 } 832 833 // Recursively share this submodule (creates repo + builds Pages) 834 console.log(`GitHubService: Sharing submodule: ${udd.title}`); 835 const result = await this.shareDreamNode(submodulePath, udd.uuid, visitedUUIDs); 836 837 // Update submodule's .udd with GitHub URLs 838 udd.githubRepoUrl = result.repoUrl; 839 if (result.pagesUrl) { 840 udd.githubPagesUrl = result.pagesUrl; 841 } 842 await this.writeUDD(submodulePath, udd); 843 844 // Commit .udd update in submodule 845 try { 846 await execAsync( 847 'git add .udd && git commit -m "Add GitHub URLs to .udd" && git push github main || true', 848 { cwd: submodulePath } 849 ); 850 } catch (error) { 851 console.warn('GitHubService: Failed to commit .udd update in submodule:', error); 852 } 853 854 return { 855 uuid: udd.uuid, 856 githubUrl: result.repoUrl, 857 title: udd.title, 858 relativePath: path.basename(submodulePath) 859 }; 860 } 861 862 /** 863 * Unpublish a single submodule recursively 864 */ 865 private async unpublishSubmodule( 866 submodulePath: string, 867 vaultPath: string, 868 visitedUUIDs: Set<string> 869 ): Promise<{ uuid: string; title: string }> { 870 // Read submodule's .udd 871 const udd = await this.readUDD(submodulePath); 872 873 // Check for circular dependencies 874 if (visitedUUIDs.has(udd.uuid)) { 875 console.log(`GitHubService: Skipping circular dependency: ${udd.title}`); 876 return { uuid: udd.uuid, title: udd.title }; 877 } 878 879 // Mark as visited 880 visitedUUIDs.add(udd.uuid); 881 882 // Check if this submodule is even published 883 if (!udd.githubRepoUrl) { 884 console.log(`GitHubService: Submodule not published, skipping: ${udd.title}`); 885 return { uuid: udd.uuid, title: udd.title }; 886 } 887 888 // Recursively unpublish this submodule 889 console.log(`GitHubService: Unpublishing submodule: ${udd.title}`); 890 await this.unpublishDreamNode(submodulePath, udd.uuid, vaultPath, visitedUUIDs); 891 892 return { uuid: udd.uuid, title: udd.title }; 893 } 894 895 /** 896 * Unpublish DreamNode from GitHub (delete remote repo, clean metadata) 897 */ 898 async unpublishDreamNode( 899 dreamNodePath: string, 900 dreamNodeUuid: string, 901 vaultPath: string, 902 visitedUUIDs: Set<string> = new Set() 903 ): Promise<void> { 904 // Read .udd to get GitHub info 905 const udd = await this.readUDD(dreamNodePath); 906 907 // Mark as visited 908 visitedUUIDs.add(dreamNodeUuid); 909 910 // Check if published 911 if (!udd.githubRepoUrl) { 912 throw new Error(`DreamNode "${udd.title}" is not published to GitHub`); 913 } 914 915 // Step 1: Unpublish all submodules recursively (depth-first) 916 const submodules = await this.getSubmodules(dreamNodePath, vaultPath); 917 console.log(`GitHubService: Found ${submodules.length} submodule(s) for ${udd.title}`); 918 919 for (const submodule of submodules) { 920 try { 921 console.log(`GitHubService: Processing submodule at ${submodule.path}`); 922 await this.unpublishSubmodule(submodule.path, vaultPath, visitedUUIDs); 923 } catch (error) { 924 console.error(`GitHubService: Failed to unpublish submodule ${submodule.name}:`, error); 925 // Continue with other submodules 926 } 927 } 928 929 // Step 2: Delete gh-pages branch first (if exists) 930 const repoMatch = udd.githubRepoUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 931 if (repoMatch) { 932 const [, owner, repo] = repoMatch; 933 const cleanRepo = repo.replace(/\.git$/, ''); 934 935 try { 936 const ghPath = await this.detectGhPath(); 937 938 // Try to delete gh-pages branch 939 console.log(`GitHubService: Deleting gh-pages branch from ${owner}/${cleanRepo}`); 940 try { 941 await execAsync(`"${ghPath}" api -X DELETE "repos/${owner}/${cleanRepo}/git/refs/heads/gh-pages"`); 942 console.log(`GitHubService: gh-pages branch deleted`); 943 } catch { 944 // Branch might not exist - that's okay 945 console.log(`GitHubService: gh-pages branch not found (may not exist)`); 946 } 947 948 // Delete GitHub repository 949 console.log(`GitHubService: Deleting GitHub repository: ${owner}/${cleanRepo}`); 950 await execAsync(`"${ghPath}" repo delete ${owner}/${cleanRepo} --yes`); 951 } catch (error) { 952 const errorMessage = error instanceof Error ? error.message : String(error); 953 954 // Check for missing delete_repo scope 955 if (errorMessage.includes('delete_repo')) { 956 throw new Error( 957 'GitHub CLI needs "delete_repo" permission.\n\n' + 958 'Run this command in your terminal:\n' + 959 'gh auth refresh -h github.com -s delete_repo' 960 ); 961 } 962 963 // Check for already deleted 964 if (errorMessage.includes('404') || errorMessage.includes('Not Found')) { 965 console.log(`GitHubService: Repository already deleted: ${owner}/${cleanRepo}`); 966 // Continue - repo already deleted 967 } else { 968 console.warn(`GitHubService: Failed to delete GitHub repo:`, error); 969 throw new Error(`Failed to delete repository: ${errorMessage}`); 970 } 971 } 972 } 973 974 // Step 3: Remove github remote from local repo 975 try { 976 await execAsync('git remote remove github', { cwd: dreamNodePath }); 977 console.log(`GitHubService: Removed 'github' remote from local repo`); 978 } catch (error) { 979 console.warn(`GitHubService: Failed to remove github remote (may not exist):`, error); 980 // Continue - remote might not exist 981 } 982 983 // Step 4: Clean .udd file 984 delete udd.githubRepoUrl; 985 delete udd.githubPagesUrl; 986 await this.writeUDD(dreamNodePath, udd); 987 console.log(`GitHubService: Cleaned GitHub URLs from .udd`); 988 989 // Step 5: Commit .udd changes 990 try { 991 await execAsync( 992 'git add .udd && git commit -m "Remove GitHub URLs from .udd" || true', 993 { cwd: dreamNodePath } 994 ); 995 } catch (error) { 996 console.warn(`GitHubService: Failed to commit .udd cleanup:`, error); 997 } 998 } 999 1000 /** 1001 * Complete share workflow: create repo, enable Pages, update UDD 1002 */ 1003 async shareDreamNode( 1004 dreamNodePath: string, 1005 dreamNodeUuid: string, 1006 visitedUUIDs: Set<string> = new Set() 1007 ): Promise<GitHubShareResult> { 1008 // Read .udd to get title 1009 const udd = await this.readUDD(dreamNodePath); 1010 1011 // Mark this node as visited (prevent circular deps) 1012 visitedUUIDs.add(dreamNodeUuid); 1013 1014 // Check if already shared - .udd file has githubRepoUrl 1015 if (udd.githubRepoUrl) { 1016 console.log(`GitHubService: ${udd.title} already shared to GitHub: ${udd.githubRepoUrl}`); 1017 1018 // Generate Obsidian URI 1019 const obsidianUri = this.generateObsidianURI(udd.githubRepoUrl); 1020 1021 return { 1022 repoUrl: udd.githubRepoUrl, 1023 pagesUrl: udd.githubPagesUrl, 1024 obsidianUri 1025 }; 1026 } 1027 1028 // Check if 'github' remote exists (edge case: previously shared but .udd not updated) 1029 try { 1030 const { stdout } = await execAsync('git remote get-url github', { cwd: dreamNodePath }); 1031 const existingGitHubUrl = stdout.trim(); 1032 1033 if (existingGitHubUrl) { 1034 console.log(`GitHubService: Found existing 'github' remote for ${udd.title}: ${existingGitHubUrl}`); 1035 console.log(`GitHubService: Updating .udd file with missing GitHub URL`); 1036 1037 // Update .udd with missing GitHub URL 1038 udd.githubRepoUrl = existingGitHubUrl; 1039 await this.writeUDD(dreamNodePath, udd); 1040 1041 // Try to get GitHub Pages URL 1042 const match = existingGitHubUrl.match(/github\.com\/([^/]+)\/([^/\s]+)/); 1043 if (match) { 1044 const [, owner, repo] = match; 1045 const cleanRepo = repo.replace(/\.git$/, ''); 1046 udd.githubPagesUrl = `https://${owner}.github.io/${cleanRepo}`; 1047 await this.writeUDD(dreamNodePath, udd); 1048 } 1049 1050 // Generate Obsidian URI 1051 const obsidianUri = this.generateObsidianURI(existingGitHubUrl); 1052 1053 return { 1054 repoUrl: existingGitHubUrl, 1055 pagesUrl: udd.githubPagesUrl, 1056 obsidianUri 1057 }; 1058 } 1059 } catch { 1060 // No 'github' remote exists - this is fine, continue with normal sharing 1061 console.log(`GitHubService: No existing 'github' remote found for ${udd.title}`); 1062 } 1063 1064 // Step 1: Discover and share all submodules recursively (depth-first) 1065 const submodules = await this.getSubmodules(dreamNodePath); 1066 const sharedSubmodules: Array<{ uuid: string; githubUrl: string; title: string; relativePath: string }> = []; 1067 1068 console.log(`GitHubService: Found ${submodules.length} submodule(s) for ${udd.title}`); 1069 1070 for (const submodule of submodules) { 1071 try { 1072 console.log(`GitHubService: Processing submodule at ${submodule.path}`); 1073 const result = await this.shareSubmodule(submodule.path, visitedUUIDs); 1074 sharedSubmodules.push(result); 1075 } catch (error) { 1076 console.error(`GitHubService: Failed to share submodule ${submodule.name}:`, error); 1077 // Continue with other submodules 1078 } 1079 } 1080 1081 // Step 2: Update .gitmodules with GitHub URLs 1082 if (sharedSubmodules.length > 0) { 1083 console.log(`GitHubService: Updating .gitmodules with GitHub URLs`); 1084 await this.updateGitmodulesUrls(dreamNodePath, sharedSubmodules); 1085 1086 // Commit .gitmodules changes 1087 try { 1088 await execAsync( 1089 'git add .gitmodules && git commit -m "Update submodule URLs for GitHub" || true', 1090 { cwd: dreamNodePath } 1091 ); 1092 } catch (error) { 1093 console.warn('GitHubService: Failed to commit .gitmodules update:', error); 1094 } 1095 } 1096 1097 // Step 3: Find available repo name based on title 1098 const repoName = await this.findAvailableRepoName(udd.title); 1099 console.log(`GitHubService: Using repository name: ${repoName}`); 1100 1101 // Step 4: Create GitHub repository 1102 const repoUrl = await this.createRepo(dreamNodePath, repoName); 1103 1104 // Step 5: Build and deploy static DreamSong site 1105 let pagesUrl: string | undefined; 1106 console.log(`GitHubService: Building static site for ${udd.title}...`); 1107 try { 1108 // Use the same parsing pipeline as local rendering 1109 // This ensures GitHub Pages displays exactly what you see locally 1110 const { parseCanvasToBlocks, getMimeType } = await import('../../services/dreamsong'); 1111 1112 // Find and parse canvas file 1113 const files = fs.readdirSync(dreamNodePath); 1114 const canvasFiles = files.filter(f => f.endsWith('.canvas')); 1115 1116 let blocks: any[] = []; 1117 1118 // Fallback hierarchy: DreamSong → DreamTalk → README 1119 if (canvasFiles.length > 0) { 1120 // PRIMARY: DreamSong (canvas file) - Full rich content 1121 console.log(`GitHubService: Using DreamSong (.canvas) for GitHub Pages`); 1122 const canvasPath = path.join(dreamNodePath, canvasFiles[0]); 1123 const canvasContent = fs.readFileSync(canvasPath, 'utf-8'); 1124 const canvasData = JSON.parse(canvasContent); 1125 1126 // Parse canvas to blocks (same as local rendering) 1127 blocks = parseCanvasToBlocks(canvasData, dreamNodeUuid); 1128 1129 // Derive vault path from DreamNode path 1130 // Canvas media paths are vault-relative, not DreamNode-relative 1131 const vaultPath = path.dirname(dreamNodePath); 1132 1133 // Resolve media paths to data URLs AND extract UUIDs from submodule .udd files 1134 for (const block of blocks) { 1135 if (block.media && block.media.src && !block.media.src.startsWith('data:') && !block.media.src.startsWith('http')) { 1136 try { 1137 // Step 1: Resolve UUID from submodule .udd file (if this is submodule media) 1138 const resolvedUuid = await this.resolveSourceDreamNodeUuid(block.media.src, vaultPath); 1139 if (resolvedUuid) { 1140 block.media.sourceDreamNodeId = resolvedUuid; 1141 console.log(`GitHubService: Resolved UUID for ${block.media.src} → ${resolvedUuid}`); 1142 } 1143 1144 // Step 2: Resolve file path to data URL 1145 // Canvas paths are vault-relative (e.g., "ArkCrystal/ARK Crystal.jpeg") 1146 // Resolve relative to vault path, not DreamNode path 1147 const mediaPath = path.join(vaultPath, block.media.src); 1148 console.log(`GitHubService: Resolving media ${block.media.src} at ${mediaPath}`); 1149 1150 if (fs.existsSync(mediaPath)) { 1151 const buffer = fs.readFileSync(mediaPath); 1152 const base64 = buffer.toString('base64'); 1153 const mimeType = getMimeType(block.media.src); 1154 block.media.src = `data:${mimeType};base64,${base64}`; 1155 console.log(`GitHubService: Successfully loaded media ${block.media.src.slice(0, 50)}...`); 1156 } else { 1157 console.warn(`GitHubService: Media file not found: ${mediaPath}`); 1158 } 1159 } catch (error) { 1160 console.warn(`GitHubService: Could not load media ${block.media.src}:`, error); 1161 } 1162 } 1163 } 1164 } else if (udd.dreamTalk) { 1165 // FALLBACK 1: DreamTalk media file - Single image/video 1166 console.log(`GitHubService: No DreamSong found, using DreamTalk media fallback`); 1167 const dreamTalkPath = path.join(dreamNodePath, udd.dreamTalk); 1168 if (fs.existsSync(dreamTalkPath)) { 1169 const buffer = fs.readFileSync(dreamTalkPath); 1170 const base64 = buffer.toString('base64'); 1171 const mimeType = getMimeType(udd.dreamTalk); 1172 const dataUrl = `data:${mimeType};base64,${base64}`; 1173 1174 // Create a single media block with proper type field 1175 blocks = [{ 1176 id: 'dreamtalk-fallback', 1177 type: 'media', // IMPORTANT: Must set type for DreamSong component 1178 media: { 1179 src: dataUrl, 1180 type: mimeType.startsWith('video/') ? 'video' : 'image', 1181 alt: udd.title 1182 }, 1183 text: '', 1184 edges: [] 1185 }]; 1186 console.log(`GitHubService: Created DreamTalk fallback block`); 1187 } else { 1188 console.warn(`GitHubService: DreamTalk file not found: ${dreamTalkPath}`); 1189 } 1190 } else { 1191 // FALLBACK 2: README.md - Text content 1192 console.log(`GitHubService: No DreamSong or DreamTalk found, checking for README.md`); 1193 const readmePath = path.join(dreamNodePath, 'README.md'); 1194 if (fs.existsSync(readmePath)) { 1195 const readmeContent = fs.readFileSync(readmePath, 'utf-8'); 1196 1197 // Simple markdown to HTML conversion (basic support) 1198 let htmlContent = readmeContent 1199 // Headers 1200 .replace(/^### (.*$)/gim, '<h3>$1</h3>') 1201 .replace(/^## (.*$)/gim, '<h2>$1</h2>') 1202 .replace(/^# (.*$)/gim, '<h1>$1</h1>') 1203 // Bold 1204 .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') 1205 // Italic 1206 .replace(/\*(.*?)\*/g, '<em>$1</em>') 1207 // Links 1208 .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank">$1</a>') 1209 // Line breaks to paragraphs 1210 .split('\n\n') 1211 .map(para => para.trim() ? `<p>${para.replace(/\n/g, '<br>')}</p>` : '') 1212 .join(''); 1213 1214 // Create a single text block with proper type field 1215 blocks = [{ 1216 id: 'readme-fallback', 1217 type: 'text', // IMPORTANT: Must set type for DreamSong component 1218 text: htmlContent, 1219 edges: [] 1220 }]; 1221 console.log(`GitHubService: Created README.md fallback block with HTML conversion`); 1222 } else { 1223 console.warn(`GitHubService: No content found - no DreamSong, DreamTalk, or README.md`); 1224 } 1225 } 1226 1227 await this.buildStaticSite(dreamNodePath, dreamNodeUuid, udd.title, blocks); 1228 console.log(`GitHubService: Static site built and deployed to gh-pages branch`); 1229 1230 // Step 6: Setup GitHub Pages (configure to serve from gh-pages branch) 1231 // IMPORTANT: This must happen AFTER buildStaticSite pushes the gh-pages branch 1232 try { 1233 pagesUrl = await this.setupPages(repoUrl); 1234 console.log(`GitHubService: GitHub Pages configured: ${pagesUrl}`); 1235 } catch (error) { 1236 console.warn('GitHubService: Failed to enable GitHub Pages:', error); 1237 // Continue without Pages URL - site is deployed but Pages config might fail 1238 } 1239 1240 } catch (error) { 1241 console.warn(`GitHubService: Failed to build static site (will continue without Pages):`, error); 1242 // Non-fatal - repo is created, just no Pages hosting 1243 } 1244 1245 // Step 7: Generate Obsidian URI 1246 const obsidianUri = this.generateObsidianURI(repoUrl); 1247 1248 return { 1249 repoUrl, 1250 pagesUrl, 1251 obsidianUri 1252 }; 1253 } 1254 } 1255 1256 // Singleton instance 1257 export const githubService = new GitHubService();