/ src / features / github-publishing / services / github-service.ts
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();