/ src / features / github-publishing / commands.ts
commands.ts
  1  import { Plugin, Notice, Modal, TFile } from 'obsidian';
  2  import { UIService } from '../../core/services/ui-service';
  3  import { useInterBrainStore } from '../../core/store/interbrain-store';
  4  import { githubService } from './services/github-service';
  5  import { serviceManager } from '../../core/services/service-manager';
  6  
  7  /**
  8   * Show confirmation modal before sharing to GitHub
  9   */
 10  async function confirmRecursiveShare(
 11    plugin: Plugin,
 12    nodeName: string,
 13    submoduleCount: number
 14  ): Promise<boolean> {
 15    return new Promise((resolve) => {
 16      const modal = new Modal(plugin.app);
 17      modal.titleEl.setText('Confirm GitHub Share');
 18  
 19      const content = modal.contentEl;
 20  
 21      if (submoduleCount === 0) {
 22        content.createEl('p', {
 23          text: `Share "${nodeName}" as a public GitHub repository?`
 24        });
 25      } else {
 26        content.createEl('p', {
 27          text: `Sharing "${nodeName}" will publish:`
 28        });
 29  
 30        const list = content.createEl('ul');
 31        list.createEl('li', { text: `1 DreamNode: ${nodeName}` });
 32        list.createEl('li', {
 33          text: `${submoduleCount} related DreamNode${submoduleCount > 1 ? 's' : ''} (submodules)`
 34        });
 35  
 36        content.createEl('p', {
 37          text: 'All will be published as public GitHub repositories.',
 38          attr: { style: 'margin-top: 16px; font-weight: 500;' }
 39        });
 40      }
 41  
 42      const buttonContainer = content.createEl('div', {
 43        attr: { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;' }
 44      });
 45  
 46      const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
 47      cancelBtn.addEventListener('click', () => {
 48        modal.close();
 49        resolve(false);
 50      });
 51  
 52      const confirmBtn = buttonContainer.createEl('button', {
 53        text: 'Share to GitHub',
 54        cls: 'mod-cta'
 55      });
 56  
 57      confirmBtn.addEventListener('click', () => {
 58        modal.close();
 59        resolve(true);
 60      });
 61  
 62      modal.open();
 63    });
 64  }
 65  
 66  /**
 67   * Show confirmation modal before unpublishing from GitHub
 68   */
 69  async function confirmRecursiveUnpublish(
 70    plugin: Plugin,
 71    nodeName: string,
 72    submoduleCount: number
 73  ): Promise<boolean> {
 74    return new Promise((resolve) => {
 75      const modal = new Modal(plugin.app);
 76      modal.titleEl.setText('Confirm GitHub Unpublish');
 77  
 78      const content = modal.contentEl;
 79  
 80      content.createEl('p', {
 81        text: '⚠️ This will permanently delete repositories from GitHub.',
 82        attr: { style: 'color: #ff6b6b; font-weight: 500; margin-bottom: 12px;' }
 83      });
 84  
 85      if (submoduleCount === 0) {
 86        content.createEl('p', {
 87          text: `Unpublish "${nodeName}" from GitHub?`
 88        });
 89      } else {
 90        content.createEl('p', {
 91          text: `Unpublishing "${nodeName}" will delete:`
 92        });
 93  
 94        const list = content.createEl('ul');
 95        list.createEl('li', { text: `1 DreamNode: ${nodeName}` });
 96        list.createEl('li', {
 97          text: `${submoduleCount} related DreamNode${submoduleCount > 1 ? 's' : ''} (submodules)`
 98        });
 99      }
100  
101      content.createEl('p', {
102        text: 'This will delete the GitHub repositories and clean local metadata.',
103        attr: { style: 'margin-top: 16px; font-size: 12px; opacity: 0.8;' }
104      });
105  
106      const buttonContainer = content.createEl('div', {
107        attr: { style: 'display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px;' }
108      });
109  
110      const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel', cls: 'mod-cta' });
111      cancelBtn.addEventListener('click', () => {
112        modal.close();
113        resolve(false);
114      });
115  
116      const confirmBtn = buttonContainer.createEl('button', {
117        text: 'Delete from GitHub',
118        attr: { style: 'background-color: #ff6b6b;' }
119      });
120  
121      confirmBtn.addEventListener('click', () => {
122        modal.close();
123        resolve(true);
124      });
125  
126      modal.open();
127    });
128  }
129  
130  /**
131   * GitHub commands for public broadcasting via GitHub Pages
132   * Philosophy: "Radicle for collaboration, GitHub Pages for publishing"
133   */
134  export function registerGitHubCommands(
135    plugin: Plugin,
136    uiService: UIService
137  ): void {
138    const path = require('path');
139    const vaultService = serviceManager.getVaultService();
140  
141    // Set plugin directory for GitHubService (needed for viewer bundle path)
142    const vaultPath = vaultService?.getVaultPath() || '';
143    if (vaultPath) {
144      const pluginDir = path.join(vaultPath, '.obsidian', 'plugins', plugin.manifest.id);
145      githubService.setPluginDir(pluginDir);
146    } else {
147      console.warn('[GitHubCommands] Could not determine vault path for plugin directory');
148    }
149  
150    // Publish DreamNode to GitHub - Creates public repo + GitHub Pages
151    plugin.addCommand({
152      id: 'publish-dreamnode-github',
153      name: 'Publish DreamNode to GitHub',
154      callback: async () => {
155        try {
156          const store = useInterBrainStore.getState();
157          const selectedNode = store.selectedNode;
158  
159          if (!selectedNode) {
160            uiService.showError('Please select a DreamNode first');
161            return;
162          }
163  
164          // Resolve full repo path using VaultService
165          const fullRepoPath = vaultService?.getFullPath(selectedNode.repoPath) || selectedNode.repoPath;
166  
167          // Check if GitHub CLI is available
168          const availabilityCheck = await githubService.isAvailable();
169  
170          if (!availabilityCheck.available) {
171            uiService.showError(availabilityCheck.error || 'GitHub CLI not available');
172            return;
173          }
174  
175          // Step 1: Sync canvas submodules BEFORE showing confirmation (if DreamSong.canvas exists)
176          const canvasPath = `${selectedNode.repoPath}/DreamSong.canvas`;
177          const canvasFile = plugin.app.vault.getAbstractFileByPath(canvasPath);
178  
179          if (canvasFile instanceof TFile) {
180            try {
181              const submoduleManager = serviceManager.getSubmoduleManagerService();
182  
183              if (submoduleManager) {
184                await submoduleManager.syncCanvasSubmodules(canvasPath);
185              }
186            } catch (syncError) {
187              console.error('[GitHubCommands] Submodule sync error:', syncError);
188              // Continue with share even if sync fails
189            }
190          }
191  
192          // Step 2: Discover submodules and show confirmation (now with updated submodules)
193          const submodules = await githubService.getSubmodules(fullRepoPath);
194  
195          const confirmed = await confirmRecursiveShare(
196            plugin,
197            selectedNode.name,
198            submodules.length
199          );
200  
201          if (!confirmed) {
202            return;
203          }
204  
205          // Show progress indicator
206          const notice = new Notice('Sharing DreamNode to GitHub...', 0);
207  
208          try {
209            // Step 3: Complete share workflow
210            const result = await githubService.shareDreamNode(fullRepoPath, selectedNode.id);
211  
212            // Update .udd file with GitHub URLs
213            const fs = require('fs').promises;
214            const uddPath = path.join(fullRepoPath, '.udd');
215  
216            try {
217              const uddContent = await fs.readFile(uddPath, 'utf-8');
218              const udd = JSON.parse(uddContent);
219  
220              udd.githubRepoUrl = result.repoUrl;
221              if (result.pagesUrl) {
222                udd.githubPagesUrl = result.pagesUrl;
223              }
224  
225              await fs.writeFile(uddPath, JSON.stringify(udd, null, 2));
226  
227              // Commit .udd update using child_process directly
228              const { exec } = require('child_process');
229              const { promisify } = require('util');
230              const execAsync = promisify(exec);
231  
232              try {
233                await execAsync('git add .udd && git commit -m "Update .udd with GitHub URLs" && git push github main', {
234                  cwd: fullRepoPath
235                });
236              } catch (gitError) {
237                console.error('[GitHubCommands] Failed to push .udd update:', gitError);
238                // Non-critical - repo already created
239              }
240            } catch (error) {
241              console.error('[GitHubCommands] Failed to update .udd file:', error);
242            }
243  
244            // Update in-memory store to reflect GitHub URLs immediately
245            const store = useInterBrainStore.getState();
246  
247            // Update selectedNode if it matches the published node
248            if (store.selectedNode?.id === selectedNode.id) {
249              const updatedNode = {
250                ...store.selectedNode,
251                githubRepoUrl: result.repoUrl,
252                githubPagesUrl: result.pagesUrl || store.selectedNode.githubPagesUrl
253              };
254              store.setSelectedNode(updatedNode);
255            }
256  
257            // Update dreamNodes store
258            const dreamNodeData = store.dreamNodes.get(selectedNode.id);
259            if (dreamNodeData) {
260              const updatedNodeData = {
261                ...dreamNodeData,
262                node: {
263                  ...dreamNodeData.node,
264                  githubRepoUrl: result.repoUrl,
265                  githubPagesUrl: result.pagesUrl || dreamNodeData.node.githubPagesUrl
266                }
267              };
268              store.updateDreamNode(selectedNode.id, updatedNodeData);
269            }
270  
271            // Copy GitHub Pages URL to clipboard (preferred for sharing)
272            const urlToCopy = result.pagesUrl || result.repoUrl;
273            await navigator.clipboard.writeText(urlToCopy);
274  
275            // Success notification
276            notice.hide();
277            const successMessage = result.pagesUrl
278              ? `DreamNode published!\n\nWebsite: ${result.pagesUrl}\n\nURL copied to clipboard.`
279              : `DreamNode published!\n\nRepository: ${result.repoUrl}\n\nURL copied to clipboard.`;
280  
281            new Notice(successMessage, 10000);
282  
283          } catch (error) {
284            notice.hide();
285            const errorMessage = error instanceof Error ? error.message : 'Unknown error';
286            console.error('[GitHubCommands] Share workflow failed:', error);
287            uiService.showError(`Failed to share DreamNode: ${errorMessage}`);
288          }
289  
290        } catch (error) {
291          console.error('[GitHubCommands] Unexpected error:', error);
292          uiService.showError('An unexpected error occurred');
293        }
294      }
295    });
296  
297    // Unpublish DreamNode from GitHub - Deletes repo and cleans metadata
298    plugin.addCommand({
299      id: 'unpublish-dreamnode-github',
300      name: 'Unpublish DreamNode from GitHub Pages',
301      callback: async () => {
302        try {
303          const store = useInterBrainStore.getState();
304          const selectedNode = store.selectedNode;
305  
306          if (!selectedNode) {
307            uiService.showError('Please select a DreamNode first');
308            return;
309          }
310  
311          // Resolve full repo path using VaultService
312          const fullRepoPath = vaultService?.getFullPath(selectedNode.repoPath) || selectedNode.repoPath;
313  
314          // Check if GitHub CLI is available
315          const availabilityCheck = await githubService.isAvailable();
316  
317          if (!availabilityCheck.available) {
318            uiService.showError(availabilityCheck.error || 'GitHub CLI not available');
319            return;
320          }
321  
322          // Check if DreamNode is published
323          const fs = require('fs').promises;
324          const uddPath = path.join(fullRepoPath, '.udd');
325  
326          let isPublished = false;
327          let publishedSubmoduleCount = 0;
328  
329          try {
330            const uddContent = await fs.readFile(uddPath, 'utf-8');
331            const udd = JSON.parse(uddContent);
332            isPublished = !!udd.githubRepoUrl;
333  
334            // Count published submodules
335            const submodules = await githubService.getSubmodules(fullRepoPath);
336            for (const submodule of submodules) {
337              try {
338                const subUddPath = path.join(submodule.path, '.udd');
339                const subUddContent = await fs.readFile(subUddPath, 'utf-8');
340                const subUdd = JSON.parse(subUddContent);
341                if (subUdd.githubRepoUrl) {
342                  publishedSubmoduleCount++;
343                }
344              } catch {
345                // Submodule doesn't have .udd or isn't published
346              }
347            }
348          } catch (error) {
349            console.error('[GitHubCommands] Failed to read .udd file:', error);
350          }
351  
352          if (!isPublished) {
353            uiService.showError('This DreamNode is not published to GitHub');
354            return;
355          }
356  
357          // Show confirmation modal
358          const confirmed = await confirmRecursiveUnpublish(
359            plugin,
360            selectedNode.name,
361            publishedSubmoduleCount
362          );
363  
364          if (!confirmed) {
365            return;
366          }
367  
368          // Show progress indicator
369          const notice = new Notice('Unpublishing DreamNode from GitHub...', 0);
370  
371          try {
372            // Complete unpublish workflow
373            const currentVaultPath = vaultService?.getVaultPath() || '';
374            await githubService.unpublishDreamNode(fullRepoPath, selectedNode.id, currentVaultPath);
375  
376            // Update in-memory store to clear GitHub URLs immediately
377            const store = useInterBrainStore.getState();
378  
379            // Update selectedNode if it matches the unpublished node
380            if (store.selectedNode?.id === selectedNode.id) {
381              const updatedNode = {
382                ...store.selectedNode,
383                githubRepoUrl: undefined,
384                githubPagesUrl: undefined
385              };
386              store.setSelectedNode(updatedNode);
387            }
388  
389            // Update dreamNodes store
390            const dreamNodeData = store.dreamNodes.get(selectedNode.id);
391            if (dreamNodeData) {
392              const updatedNodeData = {
393                ...dreamNodeData,
394                node: {
395                  ...dreamNodeData.node,
396                  githubRepoUrl: undefined,
397                  githubPagesUrl: undefined
398                }
399              };
400              store.updateDreamNode(selectedNode.id, updatedNodeData);
401            }
402  
403            // Success notification
404            notice.hide();
405            new Notice(`DreamNode unpublished from GitHub successfully!`, 5000);
406  
407          } catch (error) {
408            notice.hide();
409            const errorMessage = error instanceof Error ? error.message : 'Unknown error';
410            console.error('[GitHubCommands] Unpublish workflow failed:', error);
411            uiService.showError(`Failed to unpublish DreamNode: ${errorMessage}`);
412          }
413  
414        } catch (error) {
415          console.error('[GitHubCommands] Unexpected error:', error);
416          uiService.showError('An unexpected error occurred');
417        }
418      }
419    });
420  
421    // Update GitHub Pages - Rebuilds static site without touching repo
422    plugin.addCommand({
423      id: 'update-github-pages',
424      name: 'Update GitHub Pages',
425      callback: async () => {
426        try {
427          const store = useInterBrainStore.getState();
428          const selectedNode = store.selectedNode;
429  
430          if (!selectedNode) {
431            uiService.showError('Please select a DreamNode first');
432            return;
433          }
434  
435          // Resolve full repo path using VaultService
436          const fullRepoPath = vaultService?.getFullPath(selectedNode.repoPath) || selectedNode.repoPath;
437  
438          // Check if DreamNode is published
439          const fs = require('fs').promises;
440          const uddPath = path.join(fullRepoPath, '.udd');
441  
442          let udd;
443          try {
444            const uddContent = await fs.readFile(uddPath, 'utf-8');
445            udd = JSON.parse(uddContent);
446          } catch {
447            uiService.showError('Could not read DreamNode metadata');
448            return;
449          }
450  
451          if (!udd.githubRepoUrl) {
452            uiService.showError('This DreamNode is not published to GitHub. Use "Publish to GitHub" first.');
453            return;
454          }
455  
456          // Check if GitHub CLI is available
457          const availabilityCheck = await githubService.isAvailable();
458  
459          if (!availabilityCheck.available) {
460            uiService.showError(availabilityCheck.error || 'GitHub CLI not available');
461            return;
462          }
463  
464          // Show progress indicator
465          const notice = new Notice('Updating GitHub Pages...', 0);
466  
467          try {
468            // Rebuild the static site and deploy to gh-pages
469            await githubService.rebuildGitHubPages(fullRepoPath);
470  
471            notice.hide();
472  
473            // Copy GitHub Pages URL to clipboard
474            const pagesUrl = udd.githubPagesUrl;
475            if (pagesUrl) {
476              await navigator.clipboard.writeText(pagesUrl);
477              new Notice(`GitHub Pages updated!\n\nURL copied to clipboard:\n${pagesUrl}`, 5000);
478            } else {
479              new Notice('GitHub Pages updated successfully!', 5000);
480            }
481  
482          } catch (error) {
483            notice.hide();
484            const errorMessage = error instanceof Error ? error.message : 'Unknown error';
485            console.error('[GitHubCommands] Update Pages failed:', error);
486            uiService.showError(`Failed to update GitHub Pages: ${errorMessage}`);
487          }
488  
489        } catch (error) {
490          console.error('[GitHubCommands] Unexpected error:', error);
491          uiService.showError('An unexpected error occurred');
492        }
493      }
494    });
495  
496    // Open InterBrain GitHub repository in browser
497    plugin.addCommand({
498      id: 'open-github-repo',
499      name: 'Open InterBrain on GitHub',
500      callback: async () => {
501        // Open the InterBrain GitHub repository
502        window.open('https://github.com/ProjectLiminality/InterBrain', '_blank');
503      }
504    });
505  
506    // Clone DreamNode from GitHub - Supports Obsidian URI protocol
507    plugin.addCommand({
508      id: 'clone-dreamnode-github',
509      name: 'Clone DreamNode from GitHub',
510      callback: async () => {
511        try {
512          // Prompt for GitHub URL
513          const githubUrl = await new Promise<string>((resolve) => {
514            const modal = new (require('obsidian').Modal)(plugin.app);
515            modal.titleEl.setText('Clone DreamNode from GitHub');
516  
517            const inputEl = modal.contentEl.createEl('input', {
518              attr: {
519                type: 'text',
520                placeholder: 'github.com/user/dreamnode-uuid',
521                style: 'width: 100%; padding: 8px; margin: 16px 0;'
522              }
523            });
524  
525            const buttonContainer = modal.contentEl.createEl('div', {
526              attr: { style: 'display: flex; justify-content: flex-end; gap: 8px;' }
527            });
528  
529            const cancelBtn = buttonContainer.createEl('button', { text: 'Cancel' });
530            cancelBtn.addEventListener('click', () => {
531              modal.close();
532              resolve('');
533            });
534  
535            const cloneBtn = buttonContainer.createEl('button', {
536              text: 'Clone',
537              cls: 'mod-cta'
538            });
539  
540            cloneBtn.addEventListener('click', () => {
541              const url = inputEl.value.trim();
542              modal.close();
543  
544              // Handle both full URLs and short format
545              if (url.startsWith('http')) {
546                resolve(url);
547              } else if (url.includes('github.com')) {
548                resolve(`https://${url}`);
549              } else {
550                resolve(`https://github.com/${url}`);
551              }
552            });
553  
554            modal.open();
555          });
556  
557          if (!githubUrl) {
558            return; // User cancelled
559          }
560  
561          // Extract repo name for destination
562          const match = githubUrl.match(/github\.com\/[^/]+\/([^/\s]+)/);
563          if (!match) {
564            uiService.showError('Invalid GitHub URL');
565            return;
566          }
567  
568          const repoName = match[1].replace(/\.git$/, '');
569          const destinationPath = vaultService?.getFullPath(repoName) || repoName;
570  
571          // Show progress
572          const notice = new Notice('Cloning DreamNode from GitHub...', 0);
573  
574          try {
575            await githubService.clone(githubUrl, destinationPath);
576  
577            notice.hide();
578            new Notice(`DreamNode cloned successfully!`, 5000);
579  
580          } catch (error) {
581            notice.hide();
582            const errorMessage = error instanceof Error ? error.message : 'Unknown error';
583            console.error('[GitHubCommands] Clone failed:', error);
584            uiService.showError(`Failed to clone: ${errorMessage}`);
585          }
586  
587        } catch (error) {
588          console.error('[GitHubCommands] Unexpected error:', error);
589          uiService.showError('An unexpected error occurred');
590        }
591      }
592    });
593  }