/ src / features / github-sharing / GitHubService.ts
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();