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 }