mirror of
https://github.com/harvard-edge/cs249r_book.git
synced 2026-03-11 17:49:25 -05:00
- Modified publish-live workflow to download PDF from artifacts and upload to release assets - Updated quarto-build deployment to copy PDF to assets but exclude from git commits - Added PDF exclusion rules to .gitignore - Removed PDF commit steps from publish.sh and binder scripts - Created test script to verify PDF handling - Added comprehensive documentation for the new workflow This ensures PDF is available for download but not tracked in git repository, keeping the repo clean while maintaining accessibility.
2404 lines
101 KiB
Python
Executable File
2404 lines
101 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Book Binder - Beautiful Self-Contained CLI for MLSysBook
|
|
A gorgeous, lightning-fast, completely self-contained book development tool
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import subprocess
|
|
import shutil
|
|
import re
|
|
import signal
|
|
from pathlib import Path
|
|
from contextlib import contextmanager
|
|
from datetime import datetime
|
|
|
|
from rich.console import Console
|
|
from rich.panel import Panel
|
|
from rich.table import Table
|
|
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn
|
|
from rich.tree import Tree
|
|
from rich.text import Text
|
|
from rich.live import Live
|
|
from rich import print as rprint
|
|
|
|
console = Console()
|
|
|
|
class BookBinder:
|
|
def __init__(self):
|
|
self.root_dir = Path.cwd()
|
|
self.book_dir = self.root_dir / "book"
|
|
self.build_dir = self.root_dir / "build"
|
|
self.html_config = self.book_dir / "config" / "_quarto-html.yml"
|
|
self.pdf_config = self.book_dir / "config" / "_quarto-pdf.yml"
|
|
self.active_config = self.book_dir / "_quarto.yml"
|
|
|
|
def show_banner(self):
|
|
"""Display beautiful banner"""
|
|
banner = Panel.fit(
|
|
"[bold blue]📚 Book Binder[/bold blue]\n"
|
|
"[dim]⚡ I compile ML systems knowledge[/dim]",
|
|
border_style="blue",
|
|
padding=(1, 2)
|
|
)
|
|
console.print(banner)
|
|
|
|
def find_chapters(self):
|
|
"""Find all available chapters"""
|
|
contents_dir = self.book_dir / "contents"
|
|
if not contents_dir.exists():
|
|
return []
|
|
|
|
chapters = []
|
|
for qmd_file in contents_dir.rglob("*.qmd"):
|
|
if "images" not in str(qmd_file):
|
|
rel_path = qmd_file.relative_to(contents_dir)
|
|
chapter_name = str(rel_path).replace(".qmd", "")
|
|
chapters.append(chapter_name)
|
|
|
|
return sorted(chapters)
|
|
|
|
def find_chapter_file(self, partial_name):
|
|
"""Find chapter file that matches partial name"""
|
|
contents_dir = self.book_dir / "contents"
|
|
|
|
# Try direct glob match first
|
|
matches = list(contents_dir.rglob(f"*{partial_name}*.qmd"))
|
|
if matches:
|
|
# Filter out images directories
|
|
valid_matches = [m for m in matches if "images" not in str(m)]
|
|
if valid_matches:
|
|
return valid_matches[0]
|
|
|
|
return None
|
|
|
|
def find_chapter_match(self, partial_name):
|
|
"""Find chapter that matches partial name"""
|
|
file_path = self.find_chapter_file(partial_name)
|
|
if file_path:
|
|
rel_path = file_path.relative_to(self.book_dir / "contents")
|
|
return str(rel_path).replace(".qmd", "")
|
|
return None
|
|
|
|
def get_status(self):
|
|
"""Get current configuration status"""
|
|
if self.active_config.is_symlink():
|
|
target = self.active_config.readlink()
|
|
active_config = f"Symlink: {target}"
|
|
else:
|
|
active_config = "NOT a symlink"
|
|
|
|
# Check for commented lines
|
|
html_commented = 0
|
|
pdf_commented = 0
|
|
|
|
try:
|
|
if self.html_config.exists():
|
|
with open(self.html_config, 'r') as f:
|
|
html_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line)
|
|
except:
|
|
pass
|
|
|
|
try:
|
|
if self.pdf_config.exists():
|
|
with open(self.pdf_config, 'r') as f:
|
|
pdf_commented = sum(1 for line in f if "FAST_BUILD_COMMENTED" in line)
|
|
except:
|
|
pass
|
|
|
|
|
|
|
|
return {
|
|
'active_config': active_config,
|
|
'html_commented': html_commented,
|
|
'pdf_commented': pdf_commented,
|
|
'is_clean': html_commented == 0 and pdf_commented == 0
|
|
}
|
|
|
|
def show_symlink_status(self):
|
|
"""Show simple symlink status"""
|
|
if self.active_config.is_symlink():
|
|
target = self.active_config.readlink()
|
|
console.print(f"[bold cyan]→ _quarto.yml: {target}[/bold cyan]")
|
|
console.print() # Add blank line for spacing
|
|
else:
|
|
console.print(f"[bold yellow]⚠️ _quarto.yml is NOT a symlink[/bold yellow]")
|
|
console.print() # Add blank line for spacing
|
|
|
|
def show_status(self):
|
|
"""Display beautiful status information"""
|
|
status = self.get_status()
|
|
|
|
table = Table(title="📊 Current Status", show_header=False, box=None)
|
|
table.add_column("", style="cyan", no_wrap=True)
|
|
table.add_column("", style="white")
|
|
|
|
table.add_row("🔗 Active Config", f"[bold]{status['active_config']}[/bold]")
|
|
|
|
if status['is_clean']:
|
|
table.add_row("✅ State", "[green]Configs are clean[/green]")
|
|
else:
|
|
table.add_row("⚠️ State", f"[yellow]{status['html_commented'] + status['pdf_commented']} commented lines[/yellow]")
|
|
|
|
console.print(Panel(table, border_style="green"))
|
|
|
|
def show_chapters(self):
|
|
"""Display available chapters in a beautiful format"""
|
|
chapters = self.find_chapters()
|
|
|
|
tree = Tree("📚 [bold blue]Available Chapters[/bold blue]")
|
|
|
|
# Group by category
|
|
categories = {}
|
|
for chapter in chapters:
|
|
parts = chapter.split('/')
|
|
if len(parts) > 1:
|
|
category = parts[0]
|
|
name = '/'.join(parts[1:])
|
|
else:
|
|
category = "root"
|
|
name = chapter
|
|
|
|
if category not in categories:
|
|
categories[category] = []
|
|
categories[category].append(name)
|
|
|
|
for category, items in sorted(categories.items()):
|
|
category_node = tree.add(f"[bold cyan]{category}[/bold cyan]")
|
|
for item in sorted(items):
|
|
category_node.add(f"[white]{item}[/white]")
|
|
|
|
console.print(tree)
|
|
|
|
@contextmanager
|
|
def progress_context(self, description):
|
|
"""Context manager for progress display"""
|
|
with Progress(
|
|
SpinnerColumn(),
|
|
TextColumn("[progress.description]{task.description}"),
|
|
BarColumn(),
|
|
TimeElapsedColumn(),
|
|
console=console
|
|
) as progress:
|
|
task = progress.add_task(description, total=None)
|
|
yield progress
|
|
progress.update(task, completed=True)
|
|
|
|
def run_command(self, cmd, cwd=None, description=None, capture_for_parsing=False):
|
|
"""Run command with progress indicator and optional output capture"""
|
|
if isinstance(cmd, str):
|
|
cmd = cmd.split()
|
|
|
|
desc = description or f"Running: {' '.join(cmd)}"
|
|
|
|
try:
|
|
with self.progress_context(desc):
|
|
if capture_for_parsing:
|
|
# Capture output for parsing while still showing it
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd or self.root_dir,
|
|
capture_output=True,
|
|
text=True
|
|
)
|
|
# Print the output so user still sees it
|
|
if result.stdout:
|
|
print(result.stdout, end='')
|
|
if result.stderr:
|
|
print(result.stderr, end='')
|
|
|
|
# Return both success status and captured output
|
|
return result.returncode == 0, result.stdout + result.stderr
|
|
else:
|
|
# Original behavior - stream output directly
|
|
result = subprocess.run(
|
|
cmd,
|
|
cwd=cwd or self.root_dir,
|
|
capture_output=False,
|
|
text=True
|
|
)
|
|
return result.returncode == 0
|
|
except Exception as e:
|
|
console.print(f"[red]Error: {e}[/red]")
|
|
return False
|
|
|
|
def open_output_file(self, output_text):
|
|
"""Parse quarto output for 'Output created:' and open the file"""
|
|
lines = output_text.split('\n')
|
|
for line in lines:
|
|
if 'Output created:' in line:
|
|
# Extract the file path after "Output created: "
|
|
file_path = line.split('Output created: ', 1)[1].strip()
|
|
|
|
# Fix quarto's incorrect relative path calculation
|
|
# Quarto outputs ../../../../build/... but it should be ../build/...
|
|
if file_path.startswith('../../../../build/'):
|
|
file_path = file_path.replace('../../../../build/', '../build/')
|
|
|
|
# Convert relative path to absolute - quarto runs from book dir
|
|
if not os.path.isabs(file_path):
|
|
# Path is relative to book directory, resolve it properly
|
|
full_path = (self.book_dir / file_path).resolve()
|
|
else:
|
|
full_path = Path(file_path)
|
|
|
|
if full_path.exists():
|
|
console.print(f"[green]🌐 Opening in browser: {full_path.name}[/green]")
|
|
|
|
try:
|
|
# Use 'open' command on macOS
|
|
subprocess.run(['open', str(full_path)], check=True)
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
console.print(f"[yellow]⚠️ Could not open file automatically: {full_path}[/yellow]")
|
|
return False
|
|
else:
|
|
console.print(f"[yellow]⚠️ Output file not found: {full_path}[/yellow]")
|
|
console.print(f"[dim] Tried: {full_path}[/dim]")
|
|
return False
|
|
|
|
# No "Output created:" line found
|
|
return False
|
|
|
|
def open_browser_if_needed(self, file_path):
|
|
"""Open a file in the browser if it exists"""
|
|
if not os.path.isabs(file_path):
|
|
# Path is relative to book directory
|
|
full_path = (self.book_dir / file_path).resolve()
|
|
else:
|
|
full_path = Path(file_path)
|
|
|
|
if full_path.exists():
|
|
console.print(f"[green]🌐 Opening in browser: {full_path.name}[/green]")
|
|
|
|
try:
|
|
# Use 'open' command on macOS
|
|
subprocess.run(['open', str(full_path)], check=True)
|
|
return True
|
|
except subprocess.CalledProcessError:
|
|
console.print(f"[yellow]⚠️ Could not open file automatically: {full_path}[/yellow]")
|
|
return False
|
|
else:
|
|
console.print(f"[yellow]⚠️ Output file not found: {full_path}[/yellow]")
|
|
return False
|
|
|
|
def set_fast_build_mode(self, config_file, target_path):
|
|
"""Set fast build mode - different approach for HTML vs PDF"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
# Create backup
|
|
shutil.copy2(config_file, backup_path)
|
|
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
target_stem = target_path.stem
|
|
target_relative_path = target_path.relative_to(self.book_dir).as_posix()
|
|
|
|
# Different approach for HTML (website) vs PDF (book)
|
|
if 'type: website' in content:
|
|
# HTML: Use project.render configuration
|
|
render_config = f""" render:
|
|
- index.qmd
|
|
- 404.qmd
|
|
- contents/frontmatter/
|
|
- {target_relative_path}
|
|
# Fast build: only render essential files and target chapter
|
|
"""
|
|
|
|
# Insert render config after "navigate: true" in project section
|
|
if 'navigate: true' in content:
|
|
content = content.replace(
|
|
'navigate: true',
|
|
f'navigate: true\n{render_config}'
|
|
)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not find project navigation section in {config_file}[/yellow]")
|
|
return False
|
|
|
|
elif 'type: book' in content:
|
|
# PDF: Comment out unwanted chapters in book.chapters list
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
in_chapters_section = False
|
|
target_found = False
|
|
|
|
for line in lines:
|
|
if line.strip() == 'chapters:':
|
|
in_chapters_section = True
|
|
new_lines.append(line)
|
|
continue
|
|
elif in_chapters_section and line.startswith(' - ') and '.qmd' in line:
|
|
# This is a chapter line
|
|
if target_relative_path in line:
|
|
# Keep the target chapter
|
|
new_lines.append(line)
|
|
target_found = True
|
|
elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']):
|
|
# Keep essential frontmatter
|
|
new_lines.append(line)
|
|
else:
|
|
# Comment out other chapters
|
|
new_lines.append(f" # {line.strip()[2:]} # Commented for fast build")
|
|
elif in_chapters_section and line.strip() and not line.startswith(' '):
|
|
# End of chapters section
|
|
in_chapters_section = False
|
|
new_lines.append(line)
|
|
else:
|
|
new_lines.append(line)
|
|
|
|
if not target_found:
|
|
console.print(f"[yellow]⚠️ Target chapter {target_relative_path} not found in chapters list[/yellow]")
|
|
return False
|
|
|
|
content = '\n'.join(new_lines)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Unknown project type in {config_file}[/yellow]")
|
|
return False
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
console.print(f" 📝 Fast build mode: Only rendering {target_stem} + essential files")
|
|
|
|
return True
|
|
|
|
def set_fast_build_mode_multiple(self, config_file, target_paths):
|
|
"""Set fast build mode for multiple chapters in a single render"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
# Create backup
|
|
shutil.copy2(config_file, backup_path)
|
|
|
|
# Read current config
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Get relative paths for all targets
|
|
target_relative_paths = [path.relative_to(self.book_dir).as_posix() for path in target_paths]
|
|
target_stems = [path.stem for path in target_paths]
|
|
|
|
# Different approach for HTML (website) vs PDF (book)
|
|
if 'type: website' in content:
|
|
# HTML: Use project.render configuration with all target chapters
|
|
render_items = [
|
|
" - index.qmd",
|
|
" - 404.qmd",
|
|
" - contents/frontmatter/"
|
|
]
|
|
|
|
# Add all target chapters
|
|
for target_path in target_relative_paths:
|
|
render_items.append(f" - {target_path}")
|
|
|
|
render_config = f""" render:
|
|
{chr(10).join(render_items)}
|
|
# Fast build: only render essential files and target chapters
|
|
"""
|
|
|
|
# Insert render config after "navigate: true" in project section
|
|
if 'navigate: true' in content:
|
|
content = content.replace(
|
|
'navigate: true',
|
|
f'navigate: true\n{render_config}'
|
|
)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not find project navigation section in {config_file}[/yellow]")
|
|
return False
|
|
|
|
elif 'type: book' in content:
|
|
# PDF: Comment out unwanted chapters in book.chapters list
|
|
lines = content.split('\n')
|
|
new_lines = []
|
|
in_chapters_section = False
|
|
targets_found = 0
|
|
|
|
for line in lines:
|
|
if line.strip() == 'chapters:':
|
|
in_chapters_section = True
|
|
new_lines.append(line)
|
|
continue
|
|
elif in_chapters_section and line.startswith(' - ') and '.qmd' in line:
|
|
# This is a chapter line
|
|
if any(target_path in line for target_path in target_relative_paths):
|
|
# Keep target chapters
|
|
new_lines.append(line)
|
|
targets_found += 1
|
|
elif any(essential in line for essential in ['index.qmd', 'foreword.qmd', 'about/', 'acknowledgements/']):
|
|
# Keep essential frontmatter
|
|
new_lines.append(line)
|
|
else:
|
|
# Comment out other chapters
|
|
new_lines.append(f" # {line.strip()[2:]} # Commented for fast build")
|
|
elif in_chapters_section and line.strip() and not line.startswith(' '):
|
|
# End of chapters section
|
|
in_chapters_section = False
|
|
new_lines.append(line)
|
|
else:
|
|
new_lines.append(line)
|
|
|
|
if targets_found != len(target_relative_paths):
|
|
console.print(f"[yellow]⚠️ Only {targets_found}/{len(target_relative_paths)} target chapters found in chapters list[/yellow]")
|
|
return False
|
|
|
|
content = '\n'.join(new_lines)
|
|
else:
|
|
console.print(f"[yellow]⚠️ Unknown project type in {config_file}[/yellow]")
|
|
return False
|
|
|
|
# Write modified config
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(content)
|
|
|
|
console.print(f" 📝 Fast build mode: Only rendering {', '.join(target_stems)} + essential files")
|
|
|
|
return True
|
|
|
|
def restore_config(self, config_file):
|
|
"""Restore configuration from backup"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
if backup_path.exists():
|
|
shutil.copy2(backup_path, config_file)
|
|
backup_path.unlink()
|
|
console.print(f" 🔄 Restored from backup: {backup_path.name}")
|
|
else:
|
|
console.print(f"[yellow]⚠️ No backup found: {backup_path}[/yellow]")
|
|
|
|
def ensure_clean_config(self, config_file):
|
|
"""Ensure config is in clean state (remove any render restrictions)"""
|
|
backup_path = config_file.with_suffix('.yml.fast-build-backup')
|
|
|
|
if backup_path.exists():
|
|
# Restore from backup if it exists
|
|
shutil.copy2(backup_path, config_file)
|
|
backup_path.unlink()
|
|
console.print(f" ✅ {config_file.name} restored from backup")
|
|
else:
|
|
# Check if file has render restrictions and remove them
|
|
with open(config_file, 'r', encoding='utf-8') as f:
|
|
content = f.read()
|
|
|
|
# Remove render configuration if present
|
|
lines = content.split('\n')
|
|
cleaned_lines = []
|
|
in_render_section = False
|
|
|
|
for line in lines:
|
|
if line.strip().startswith('render:'):
|
|
in_render_section = True
|
|
continue
|
|
elif in_render_section and (line.startswith(' - ') or line.startswith(' #')):
|
|
continue
|
|
elif in_render_section and line.strip() == '':
|
|
continue
|
|
else:
|
|
in_render_section = False
|
|
cleaned_lines.append(line)
|
|
|
|
cleaned_content = '\n'.join(cleaned_lines)
|
|
|
|
if cleaned_content != content:
|
|
with open(config_file, 'w', encoding='utf-8') as f:
|
|
f.write(cleaned_content)
|
|
console.print(f" ✅ {config_file.name} cleaned (removed render restrictions)")
|
|
else:
|
|
console.print(f" ✅ {config_file.name} already clean")
|
|
|
|
def setup_symlink(self, format_type):
|
|
"""Setup _quarto.yml symlink"""
|
|
if format_type == "html":
|
|
config_file = "config/_quarto-html.yml"
|
|
elif format_type == "pdf":
|
|
config_file = "config/_quarto-pdf.yml"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# Remove existing symlink/file
|
|
if self.active_config.exists() or self.active_config.is_symlink():
|
|
self.active_config.unlink()
|
|
|
|
# Create new symlink
|
|
self.active_config.symlink_to(config_file)
|
|
|
|
return config_file
|
|
|
|
def build(self, chapters, format_type="html"):
|
|
"""Build one or more chapters with beautiful progress display"""
|
|
# Handle both single chapter and comma-separated chapters
|
|
if isinstance(chapters, str):
|
|
if ',' in chapters:
|
|
chapter_list = [ch.strip() for ch in chapters.split(',')]
|
|
return self.build_multiple(chapter_list, format_type)
|
|
else:
|
|
return self.build_single(chapters, format_type)
|
|
else:
|
|
# Assume it's already a list
|
|
return self.build_multiple(chapters, format_type)
|
|
|
|
def build_multiple(self, chapter_list, format_type="html"):
|
|
"""Build multiple chapters together in a single unified build"""
|
|
console.print(f"[green] 🚀 Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]")
|
|
console.print(f"[dim] 📋 Chapters: {', '.join(chapter_list)}[/dim]")
|
|
|
|
return self.build_multiple_unified(chapter_list, format_type)
|
|
|
|
def build_multiple_unified(self, chapter_list, format_type="html"):
|
|
"""Build multiple chapters in a single Quarto render command"""
|
|
# Resolve chapter paths
|
|
chapter_files = []
|
|
for chapter in chapter_list:
|
|
chapter_file = self.find_chapter_file(chapter)
|
|
if not chapter_file:
|
|
console.print(f"[red]❌ Chapter not found: {chapter}[/red]")
|
|
return False
|
|
chapter_files.append(chapter_file)
|
|
|
|
# Configure build settings
|
|
if format_type == "html":
|
|
config_file = self.html_config
|
|
format_arg = "html"
|
|
build_subdir = "html"
|
|
elif format_type == "pdf":
|
|
config_file = self.pdf_config
|
|
format_arg = "titlepage-pdf"
|
|
build_subdir = "pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# Create build directory
|
|
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup correct configuration symlink for the format
|
|
self.setup_symlink(format_type)
|
|
|
|
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
console.print(f"[green] 🚀 Building {len(chapter_list)} chapters together[/green] [dim]({format_type})[/dim]")
|
|
|
|
# Set up unified fast build mode for all chapters
|
|
success = self.set_fast_build_mode_multiple(config_file, chapter_files)
|
|
if not success:
|
|
return False
|
|
|
|
# Set up signal handlers for graceful shutdown
|
|
def signal_handler(signum, frame):
|
|
self.restore_config(config_file)
|
|
sys.exit(1)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with unified project.render configuration
|
|
console.print("[yellow] 🔨 Building all chapters in single render...[/yellow]")
|
|
|
|
# Render project with all target chapters
|
|
render_cmd = ["quarto", "render", "--to", format_arg]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Capture output to find the created file and open it
|
|
result = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f" Building {len(chapter_list)} chapters ({format_type}) - unified build",
|
|
capture_for_parsing=True
|
|
)
|
|
|
|
if result:
|
|
console.print(f" ✅ Unified build complete: build/{build_subdir}/")
|
|
|
|
# Open browser for HTML builds
|
|
if format_type == "html" and result:
|
|
self.open_browser_if_needed(f"../build/{build_subdir}/index.html")
|
|
|
|
return True
|
|
else:
|
|
console.print(f"[red]❌ Build failed[/red]")
|
|
return False
|
|
|
|
finally:
|
|
# Always restore config, even if build fails
|
|
console.print(" 🛡️ Ensuring pristine config restoration...")
|
|
|
|
self.restore_config(config_file)
|
|
|
|
def build_single(self, chapter, format_type="html", open_browser=True):
|
|
"""Build a single chapter with beautiful progress display"""
|
|
# Find the actual chapter file
|
|
chapter_file = self.find_chapter_file(chapter)
|
|
if not chapter_file:
|
|
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
|
|
console.print("[yellow]💡 Available chapters:[/yellow]")
|
|
self.show_chapters()
|
|
return False
|
|
|
|
# Get relative path from book directory
|
|
target_path = str(chapter_file.relative_to(self.book_dir))
|
|
chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "")
|
|
|
|
console.print(f"[green] 🚀 Building[/green] [bold]{chapter_name}[/bold] [dim]({format_type})[/dim]")
|
|
console.print(f"[dim] ✅ Found: {chapter_file}[/dim]")
|
|
|
|
# Setup configuration
|
|
if format_type == "html":
|
|
config_file = self.html_config
|
|
format_arg = "html"
|
|
build_subdir = "html"
|
|
elif format_type == "pdf":
|
|
config_file = self.pdf_config
|
|
format_arg = "titlepage-pdf"
|
|
build_subdir = "pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
# Create build directory
|
|
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup correct configuration symlink for the format
|
|
self.setup_symlink(format_type)
|
|
|
|
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
# Set fast build mode for the target chapter
|
|
self.set_fast_build_mode(config_file, chapter_file)
|
|
|
|
# Setup signal handler to restore config on Ctrl+C
|
|
def signal_handler(signum, frame):
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
|
|
self.restore_config(config_file)
|
|
console.print("[green]✅ Config restored to pristine state[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Build with project.render configuration (fast build)
|
|
console.print("[yellow] 🔨 Building with fast build configuration...[/yellow]")
|
|
|
|
# Render project with limited file scope
|
|
render_cmd = ["quarto", "render", "--to", format_arg]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
# Capture output to find the created file and open it
|
|
result = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f"Building {chapter_name} ({format_type}) - fast build",
|
|
capture_for_parsing=True
|
|
)
|
|
|
|
# Handle the returned tuple (success, output)
|
|
if isinstance(result, tuple):
|
|
success, output = result
|
|
else:
|
|
success, output = result, ""
|
|
|
|
if success:
|
|
console.print(f"[green] ✅ Fast build complete: build/{build_subdir}/[/green]")
|
|
|
|
# Automatically open the output file if HTML and requested
|
|
if format_type == "html" and output and open_browser:
|
|
# Check user preference for auto-open
|
|
preferences = self.get_user_preferences()
|
|
auto_open = preferences.get('auto_open', True)
|
|
|
|
if auto_open:
|
|
self.open_output_file(output)
|
|
else:
|
|
console.print(f"[blue]📄 Output ready: {output}[/blue]")
|
|
console.print("[dim]💡 Set auto-open preference with './binder setup'[/dim]")
|
|
else:
|
|
console.print("[red] ❌ Build failed[/red]")
|
|
|
|
return success
|
|
|
|
finally:
|
|
# Always restore config (bulletproof cleanup)
|
|
console.print("[yellow] 🛡️ Ensuring pristine config restoration...[/yellow]")
|
|
|
|
self.restore_config(config_file)
|
|
|
|
def preview(self, chapter):
|
|
"""Start preview server for a chapter"""
|
|
# Find the actual chapter file
|
|
chapter_file = self.find_chapter_file(chapter)
|
|
if not chapter_file:
|
|
console.print(f"[red]❌ No chapter found matching '{chapter}'[/red]")
|
|
return False
|
|
|
|
target_path = str(chapter_file.relative_to(self.book_dir))
|
|
chapter_name = str(chapter_file.relative_to(self.book_dir / "contents")).replace(".qmd", "")
|
|
|
|
console.print(f"[blue]🌐 Starting preview for[/blue] [bold]{chapter_name}[/bold]")
|
|
|
|
# Setup for HTML preview
|
|
config_file = self.html_config
|
|
|
|
try:
|
|
# Ensure config is clean (remove any render restrictions)
|
|
self.ensure_clean_config(config_file)
|
|
|
|
# Setup signal handler to restore config on exit
|
|
def signal_handler(signum, frame):
|
|
console.print("\n[yellow]🛡️ Ctrl+C detected - restoring config and exiting...[/yellow]")
|
|
self.restore_config(config_file)
|
|
console.print("[green]✅ Config restored to pristine state[/green]")
|
|
sys.exit(0)
|
|
|
|
signal.signal(signal.SIGINT, signal_handler)
|
|
signal.signal(signal.SIGTERM, signal_handler)
|
|
|
|
# Start preview with only the target file
|
|
try:
|
|
preview_cmd = ["quarto", "preview", target_path]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(preview_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
subprocess.run(
|
|
preview_cmd,
|
|
cwd=self.book_dir
|
|
)
|
|
finally:
|
|
# Restore config
|
|
self.restore_config(config_file)
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
# Always restore config on error
|
|
self.restore_config(config_file)
|
|
console.print(f"[red]❌ Preview failed: {e}[/red]")
|
|
return False
|
|
|
|
def clean(self):
|
|
"""Clean build artifacts and restore configs"""
|
|
console.print("[bold blue]🧹 Fast Build Cleanup[/bold blue]")
|
|
console.print("[dim]💡 Restoring master configs and basic cleanup[/dim]")
|
|
|
|
# Restore HTML config
|
|
html_config = self.book_dir / "config" / "_quarto-html.yml"
|
|
self.ensure_clean_config(html_config)
|
|
|
|
# Restore PDF config
|
|
pdf_config = self.book_dir / "config" / "_quarto-pdf.yml"
|
|
self.ensure_clean_config(pdf_config)
|
|
|
|
|
|
|
|
# Show current symlink status
|
|
symlink_path = self.book_dir / "_quarto.yml"
|
|
if symlink_path.exists() and symlink_path.is_symlink():
|
|
target = symlink_path.readlink()
|
|
console.print(f"[dim] 🔗 Current symlink: _quarto.yml → {target}[/dim]")
|
|
|
|
console.print("[green] ✅ All configs restored to clean state[/green]")
|
|
|
|
# Clean build artifacts
|
|
artifacts_to_clean = [
|
|
(self.root_dir / "build", "Build directory (all formats)"),
|
|
(self.book_dir / "index_files", "Book index files"),
|
|
(self.book_dir / ".quarto", "Quarto cache (book)")
|
|
]
|
|
|
|
# Clean Quarto-generated figure directories
|
|
contents_core = self.book_dir / "contents" / "core"
|
|
if contents_core.exists():
|
|
for chapter_dir in contents_core.glob("*/"):
|
|
if chapter_dir.is_dir():
|
|
# Look for *_files directories containing figure-html
|
|
for files_dir in chapter_dir.glob("*_files"):
|
|
if files_dir.is_dir():
|
|
figure_html_dir = files_dir / "figure-html"
|
|
if figure_html_dir.exists():
|
|
artifacts_to_clean.append((figure_html_dir, f"Quarto figure artifacts ({chapter_dir.name})"))
|
|
|
|
# Also check for any standalone figure-html directories
|
|
figure_html_direct = chapter_dir / "figure-html"
|
|
if figure_html_direct.exists():
|
|
artifacts_to_clean.append((figure_html_direct, f"Quarto figure artifacts ({chapter_dir.name})"))
|
|
|
|
cleaned_count = 0
|
|
for artifact_path, description in artifacts_to_clean:
|
|
if artifact_path.exists():
|
|
if artifact_path.is_dir():
|
|
shutil.rmtree(artifact_path)
|
|
else:
|
|
artifact_path.unlink()
|
|
|
|
console.print(f"[yellow] 🗑️ Removing: {artifact_path.name} ({description})[/yellow]")
|
|
cleaned_count += 1
|
|
|
|
if cleaned_count > 0:
|
|
console.print(f"[green] ✅ Cleaned {cleaned_count} items successfully[/green]")
|
|
else:
|
|
console.print("[green] ✅ No artifacts to clean[/green]")
|
|
|
|
def check_artifacts(self):
|
|
"""Check for build artifacts that shouldn't be committed"""
|
|
console.print("[blue]🔍 Checking for build artifacts...[/blue]")
|
|
|
|
# Check for artifacts that shouldn't be committed
|
|
artifacts_found = []
|
|
|
|
potential_artifacts = [
|
|
(self.root_dir / "build", "Build directory"),
|
|
(self.book_dir / "index_files", "Book index files"),
|
|
(self.book_dir / ".quarto", "Quarto cache"),
|
|
(self.book_dir / "config" / "_quarto-html.yml.fast-build-backup", "HTML config backup"),
|
|
(self.book_dir / "config" / "_quarto-pdf.yml.fast-build-backup", "PDF config backup"),
|
|
|
|
]
|
|
|
|
for path, description in potential_artifacts:
|
|
if path.exists():
|
|
artifacts_found.append((path, description))
|
|
|
|
if artifacts_found:
|
|
console.print("[yellow]⚠️ Build artifacts detected:[/yellow]")
|
|
for path, description in artifacts_found:
|
|
console.print(f"[yellow] 📄 {path} ({description})[/yellow]")
|
|
console.print("[blue]💡 Run './binder clean' to remove these artifacts[/blue]")
|
|
return False
|
|
else:
|
|
console.print("[green]✅ No build artifacts detected.[/green]")
|
|
return True
|
|
|
|
def switch(self, format_type):
|
|
"""Switch configuration format"""
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return False
|
|
|
|
console.print(f"[blue]🔗 Switching to {format_type} config...[/blue]")
|
|
|
|
# Clean up first
|
|
self.clean()
|
|
|
|
# Setup new symlink
|
|
config_name = self.setup_symlink(format_type)
|
|
|
|
console.print(f"[green] ✅ _quarto.yml → {config_name}[/green]")
|
|
|
|
return True
|
|
|
|
def build_full(self, format_type="html"):
|
|
"""Build full book in specified format"""
|
|
console.print(f"[green] 🔨 Building full {format_type.upper()} book...[/green]")
|
|
|
|
# Create build directory
|
|
build_subdir = format_type
|
|
(self.build_dir / build_subdir).mkdir(parents=True, exist_ok=True)
|
|
|
|
# Setup config
|
|
config_name = self.setup_symlink(format_type)
|
|
|
|
|
|
|
|
if format_type == "html":
|
|
render_to = "html"
|
|
elif format_type == "pdf":
|
|
render_to = "titlepage-pdf"
|
|
|
|
else:
|
|
raise ValueError(f"Unknown format type: {format_type}")
|
|
|
|
render_cmd = ["quarto", "render", "--to", render_to]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(render_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
success = self.run_command(
|
|
render_cmd,
|
|
cwd=self.book_dir,
|
|
description=f"Building full {format_type.upper()} book"
|
|
)
|
|
|
|
if success:
|
|
console.print(f"[green] ✅ {format_type.upper()} build complete: build/{build_subdir}/[/green]")
|
|
|
|
return success
|
|
|
|
def preview_full(self, format_type="html"):
|
|
"""Start full preview server"""
|
|
console.print(f"[blue]🌐 Starting full {format_type.upper()} preview server...[/blue]")
|
|
|
|
# Setup config
|
|
config_name = self.setup_symlink(format_type)
|
|
|
|
console.print(f"[blue] 🔗 Using {config_name}[/blue]")
|
|
console.print("[dim] 🛑 Press Ctrl+C to stop the server[/dim]")
|
|
|
|
try:
|
|
preview_cmd = ["quarto", "preview"]
|
|
|
|
# Show the raw command being executed
|
|
cmd_str = " ".join(preview_cmd)
|
|
console.print(f"[blue] 💻 Command: {cmd_str}[/blue]")
|
|
|
|
subprocess.run(preview_cmd + ["book/"], cwd=self.root_dir)
|
|
return True
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]🛑 Preview server stopped[/yellow]")
|
|
return True
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Preview failed: {e}[/red]")
|
|
return False
|
|
|
|
def publish(self):
|
|
"""Publish the book (build + deploy) - Step by step process"""
|
|
|
|
# Create prominent warning banner
|
|
warning_panel = Panel(
|
|
"[bold red]🚀 PUBLISHING WORKFLOW[/bold red]\n\n"
|
|
"[red]This will publish the book to production![/red]\n\n"
|
|
"[yellow]⚠️ This action will:[/yellow]\n"
|
|
" • Merge dev → main branch\n"
|
|
" • Build HTML and PDF versions\n"
|
|
" • Create a GitHub release\n"
|
|
" • Deploy to production\n"
|
|
" • Update the live website",
|
|
title="[bold red]⚠️ PRODUCTION DEPLOYMENT[/bold red]",
|
|
border_style="red",
|
|
padding=(1, 2)
|
|
)
|
|
console.print(warning_panel)
|
|
console.print()
|
|
|
|
console.print("[bold blue]🚀 MLSysBook Publishing Wizard[/bold blue]")
|
|
console.print("[dim]I'll guide you through publishing step by step[/dim]\n")
|
|
|
|
# Step 1: Pre-flight validation
|
|
console.print("[blue]📋 Step 1: Pre-flight Validation[/blue]")
|
|
if not self._validate_publish_environment():
|
|
return False
|
|
|
|
# Step 2: Branch handling
|
|
console.print("\n[blue]📋 Step 2: Branch Management[/blue]")
|
|
if not self._handle_branch_setup():
|
|
return False
|
|
|
|
# Step 3: Version and release planning
|
|
console.print("\n[blue]📋 Step 3: Version & Release Planning[/blue]")
|
|
release_info = self._plan_release()
|
|
if not release_info:
|
|
return False
|
|
|
|
# Step 4: Update changelog
|
|
if release_info['update_changelog']:
|
|
console.print("\n[blue]📋 Step 4: Updating Changelog[/blue]")
|
|
if not self._update_changelog(release_info['version'], release_info['change_type']):
|
|
return False
|
|
|
|
# Step 5: Build process
|
|
console.print("\n[blue]📋 Step 5: Building Book[/blue]")
|
|
if not self._build_for_publication():
|
|
return False
|
|
|
|
# Step 6: Release creation
|
|
if release_info['create_release']:
|
|
console.print("\n[blue]📋 Step 6: Creating Release[/blue]")
|
|
if not self._create_release(release_info):
|
|
return False
|
|
|
|
# Step 7: Deployment
|
|
console.print("\n[blue]📋 Step 7: Deploying to Production[/blue]")
|
|
if not self._deploy_to_production():
|
|
return False
|
|
|
|
# Success!
|
|
self._show_publish_success()
|
|
return True
|
|
|
|
def _validate_publish_environment(self):
|
|
"""Step 1: Validate the publishing environment"""
|
|
try:
|
|
# Check git status
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
|
|
if current_branch not in ["main", "dev"]:
|
|
console.print(f"[red]❌ Invalid branch: {current_branch}[/red]")
|
|
console.print("[yellow]Please switch to 'main' or 'dev' branch[/yellow]")
|
|
return False
|
|
|
|
# Check for uncommitted changes
|
|
result = subprocess.run(['git', 'status', '--porcelain'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
if result.stdout.strip():
|
|
console.print("[red]❌ You have uncommitted changes[/red]")
|
|
console.print("[yellow]Please commit or stash your changes first[/yellow]")
|
|
return False
|
|
|
|
# Check for required tools
|
|
tools_status = []
|
|
|
|
# Check GitHub CLI
|
|
try:
|
|
subprocess.run(['gh', '--version'], capture_output=True, check=True)
|
|
tools_status.append(("GitHub CLI", "✅ Available"))
|
|
except:
|
|
tools_status.append(("GitHub CLI", "⚠️ Not found (releases will be manual)"))
|
|
|
|
# Check Ollama
|
|
try:
|
|
subprocess.run(['ollama', '--version'], capture_output=True, check=True)
|
|
tools_status.append(("Ollama", "✅ Available"))
|
|
except:
|
|
tools_status.append(("Ollama", "⚠️ Not found (manual release notes)"))
|
|
|
|
# Show status
|
|
status_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=30)
|
|
|
|
status_table.add_row("Git Branch", f"✅ {current_branch}")
|
|
status_table.add_row("Git Status", "✅ Clean")
|
|
for tool, status in tools_status:
|
|
status_table.add_row(tool, status)
|
|
|
|
console.print(status_table)
|
|
console.print("[green]✅ Environment validation passed[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Validation failed: {e}[/red]")
|
|
return False
|
|
|
|
def _handle_branch_setup(self):
|
|
"""Step 2: Handle branch setup and merging"""
|
|
try:
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
|
|
if current_branch == "dev":
|
|
console.print("[yellow]⚠️ You're on dev branch[/yellow]")
|
|
console.print("[blue]Should I merge dev into main?[/blue]")
|
|
console.print("[bold]Merge dev to main? (Y/n) [Y]: [/bold]", end="")
|
|
choice = input().strip().lower()
|
|
if not choice:
|
|
choice = 'y'
|
|
|
|
if choice in ['y', 'yes']:
|
|
console.print("[blue]🔄 Merging dev to main...[/blue]")
|
|
subprocess.run(['git', 'checkout', 'main'], cwd=self.root_dir, check=True)
|
|
subprocess.run(['git', 'merge', 'dev'], cwd=self.root_dir, check=True)
|
|
console.print("[green]✅ Successfully merged dev to main[/green]")
|
|
return True
|
|
else:
|
|
console.print("[yellow]⚠️ Please switch to main branch manually[/yellow]")
|
|
return False
|
|
else:
|
|
console.print("[green]✅ Already on main branch[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Branch setup failed: {e}[/red]")
|
|
return False
|
|
|
|
def _update_changelog(self, version, change_type):
|
|
"""Update CHANGELOG.md with new version entry"""
|
|
try:
|
|
console.print("[blue]📝 Updating changelog...[/blue]")
|
|
|
|
# Read current changelog
|
|
changelog_path = os.path.join(self.root_dir, 'CHANGELOG.md')
|
|
with open(changelog_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Get current date
|
|
current_date = datetime.now().strftime("%B %d")
|
|
current_year = datetime.now().year
|
|
|
|
# Create new entry
|
|
new_entry = f"""### {current_date}
|
|
|
|
<details >
|
|
<summary>**📦 Release {version}**</summary>
|
|
|
|
- `███░░` **Book Binder CLI**: Enhanced with interactive prompts, smart defaults, and improved UX
|
|
- `███░░` **Publishing Workflow**: Streamlined publishing process with step-by-step wizard
|
|
- `███░░` **Environment Setup**: Automated tool installation and configuration
|
|
- `██░░░` **Documentation**: Updated README and docs to feature binder workflow
|
|
- `█░░░░` **Bug Fixes**: Various improvements and refinements
|
|
|
|
</details>
|
|
|
|
"""
|
|
|
|
# Find the year section and add new entry
|
|
year_pattern = rf"## 📅 {current_year}"
|
|
if year_pattern in content:
|
|
# Add after the year header
|
|
content = content.replace(year_pattern, f"{year_pattern}\n\n{new_entry}")
|
|
else:
|
|
# Add new year section at the top
|
|
content = f"## 📅 {current_year}\n\n{new_entry}\n{content}"
|
|
|
|
# Write back to file
|
|
with open(changelog_path, 'w') as f:
|
|
f.write(content)
|
|
|
|
console.print("[green]✅ Changelog updated successfully[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to update changelog: {e}[/red]")
|
|
return False
|
|
|
|
def _plan_release(self):
|
|
"""Step 3: Plan version and release strategy"""
|
|
try:
|
|
# Get current version and suggest next
|
|
current_version = self._get_current_version()
|
|
suggested_patch = self._get_next_version()
|
|
|
|
# Analyze recent changes to suggest version type
|
|
change_type = self._analyze_changes()
|
|
|
|
console.print(f"[blue]Current version:[/blue] {current_version}")
|
|
console.print(f"[blue]Suggested version:[/blue] {suggested_patch}")
|
|
console.print(f"[blue]Change type:[/blue] {change_type.upper()}")
|
|
|
|
# Ask about version type
|
|
console.print("\n[blue]📦 Version Strategy:[/blue]")
|
|
console.print(" • [dim]patch[/dim] - Bug fixes, minor updates (1.0.0 → 1.0.1)")
|
|
console.print(" • [dim]minor[/dim] - New features, chapters (1.0.0 → 1.1.0)")
|
|
console.print(" • [dim]major[/dim] - Breaking changes (1.0.0 → 2.0.0)")
|
|
|
|
console.print(f"[bold]Version type (patch/minor/major) [{change_type.upper()}]: [/bold]", end="")
|
|
version_choice = input().strip().lower()
|
|
if not version_choice:
|
|
version_choice = change_type
|
|
|
|
# Generate version based on choice
|
|
if version_choice == "major":
|
|
version = self._increment_major_version(current_version)
|
|
elif version_choice == "minor":
|
|
version = self._increment_minor_version(current_version)
|
|
else:
|
|
version = suggested_patch
|
|
|
|
console.print(f"[green]✅ Selected version: {version}[/green]")
|
|
|
|
# Ask about release creation
|
|
console.print("\n[blue]📦 Create GitHub release?[/blue]")
|
|
console.print("[bold]Create release? (Y/n) [Y]: [/bold]", end="")
|
|
release_choice = input().strip().lower()
|
|
if not release_choice:
|
|
release_choice = 'y'
|
|
create_release = release_choice in ['y', 'yes']
|
|
|
|
# Ask about AI release notes (only for publishing)
|
|
use_ai_notes = False
|
|
if create_release:
|
|
# Get user preference for AI
|
|
preferences = self.get_user_preferences()
|
|
ai_default = preferences.get('ai_default', True)
|
|
ai_default_text = "y" if ai_default else "n"
|
|
|
|
console.print("\n[blue]🤖 AI Release Notes (requires Ollama):[/blue]")
|
|
ai_default_display = "Y" if ai_default else "n"
|
|
console.print(f"[bold]Use AI for release notes? (Y/n) [{ai_default_display}]: [/bold]", end="")
|
|
ai_choice = input().strip().lower()
|
|
if not ai_choice:
|
|
ai_choice = ai_default_text
|
|
use_ai_notes = ai_choice in ['y', 'yes']
|
|
|
|
# Ask about changelog update
|
|
console.print("\n[blue]📝 Update changelog?[/blue]")
|
|
console.print("[bold]Update changelog? (Y/n) [Y]: [/bold]", end="")
|
|
changelog_choice = input().strip().lower()
|
|
if not changelog_choice:
|
|
changelog_choice = 'y'
|
|
update_changelog = changelog_choice in ['y', 'yes']
|
|
|
|
return {
|
|
'version': version,
|
|
'create_release': create_release,
|
|
'use_ai_notes': use_ai_notes,
|
|
'change_type': change_type,
|
|
'update_changelog': update_changelog
|
|
}
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Release planning failed: {e}[/red]")
|
|
return None
|
|
|
|
def _build_for_publication(self):
|
|
"""Step 4: Build both HTML and PDF for publication"""
|
|
try:
|
|
# Clean previous builds
|
|
console.print("[blue]🧹 Cleaning previous builds...[/blue]")
|
|
self.clean()
|
|
|
|
# Build PDF first (as requested)
|
|
console.print("[blue]📄 Building PDF version...[/blue]")
|
|
if not self.build_full("pdf"):
|
|
console.print("[red]❌ PDF build failed![/red]")
|
|
return False
|
|
console.print("[green]✅ PDF build completed[/green]")
|
|
|
|
# Build HTML
|
|
console.print("[blue]📚 Building HTML version...[/blue]")
|
|
if not self.build_full("html"):
|
|
console.print("[red]❌ HTML build failed![/red]")
|
|
return False
|
|
console.print("[green]✅ HTML build completed[/green]")
|
|
|
|
# Validate builds
|
|
pdf_path = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
|
html_path = self.build_dir / "html" / "index.html"
|
|
|
|
if not pdf_path.exists():
|
|
console.print(f"[red]❌ PDF not found at {pdf_path}[/red]")
|
|
return False
|
|
|
|
if not html_path.exists():
|
|
console.print(f"[red]❌ HTML not found at {html_path}[/red]")
|
|
return False
|
|
|
|
console.print("[green]✅ All builds validated successfully[/green]")
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Build process failed: {e}[/red]")
|
|
return False
|
|
|
|
def _create_release(self, release_info):
|
|
"""Step 5: Create GitHub release with version and notes"""
|
|
try:
|
|
version = release_info['version']
|
|
use_ai_notes = release_info['use_ai_notes']
|
|
tag_name = f"v{version}"
|
|
|
|
console.print(f"[blue]📦 Creating release {tag_name}...[/blue]")
|
|
|
|
# Generate release notes
|
|
if use_ai_notes:
|
|
console.print("[blue]🤖 Generating AI release notes...[/blue]")
|
|
release_notes = self._generate_ai_release_notes()
|
|
else:
|
|
console.print("[blue]📝 Generating manual release notes...[/blue]")
|
|
release_notes = self._generate_manual_release_notes()
|
|
|
|
# Create and push tag
|
|
console.print("[blue]🏷️ Creating git tag...[/blue]")
|
|
subprocess.run(['git', 'tag', '-a', tag_name, '-m', f"Release {tag_name}"],
|
|
cwd=self.root_dir, check=True)
|
|
subprocess.run(['git', 'push', 'origin', tag_name],
|
|
cwd=self.root_dir, check=True)
|
|
|
|
# Create GitHub release
|
|
console.print("[blue]🚀 Creating GitHub release...[/blue]")
|
|
release_cmd = [
|
|
'gh', 'release', 'create', tag_name,
|
|
'--title', f"Release {tag_name}",
|
|
'--notes', release_notes,
|
|
'--target', 'main'
|
|
]
|
|
subprocess.run(release_cmd, cwd=self.root_dir, check=True)
|
|
|
|
console.print(f"[green]✅ Successfully created release {tag_name}[/green]")
|
|
return True
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
console.print(f"[yellow]⚠️ Release creation failed: {e}[/yellow]")
|
|
console.print("[dim]You can create the release manually on GitHub[/dim]")
|
|
return False
|
|
except FileNotFoundError:
|
|
console.print("[yellow]⚠️ GitHub CLI (gh) not found[/yellow]")
|
|
console.print("[dim]You can create the release manually on GitHub[/dim]")
|
|
return False
|
|
|
|
def _deploy_to_production(self):
|
|
"""Step 6: Deploy to production (commit, push, trigger CI/CD)"""
|
|
try:
|
|
# Copy PDF to assets
|
|
console.print("[blue]📦 Copying PDF to assets...[/blue]")
|
|
pdf_source = self.build_dir / "pdf" / "Machine-Learning-Systems.pdf"
|
|
pdf_dest = self.root_dir / "assets" / "Machine-Learning-Systems.pdf"
|
|
|
|
import shutil
|
|
shutil.copy2(pdf_source, pdf_dest)
|
|
console.print("[green]✅ PDF copied to assets[/green]")
|
|
|
|
# Note about PDF handling (not committing to git)
|
|
console.print("[blue]📄 PDF handling:[/blue]")
|
|
console.print(" ✅ PDF copied to assets/Machine-Learning-Systems.pdf")
|
|
console.print(" 📝 PDF will be available for download but not committed to git")
|
|
console.print(" 🔗 PDF will be uploaded to GitHub Release assets during publish-live workflow")
|
|
|
|
# Push to main
|
|
console.print("[blue]🚀 Pushing to main...[/blue]")
|
|
subprocess.run(['git', 'push', 'origin', 'main'],
|
|
cwd=self.root_dir, check=True)
|
|
console.print("[green]✅ Successfully pushed to main[/green]")
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Deployment failed: {e}[/red]")
|
|
return False
|
|
|
|
def _show_publish_success(self):
|
|
"""Show success summary after publishing"""
|
|
console.print("\n[green]🎉 Publication completed successfully![/green]")
|
|
console.print("\n[blue]📊 What happened:[/blue]")
|
|
console.print(" ✅ Validated environment")
|
|
console.print(" ✅ Managed branch setup")
|
|
console.print(" ✅ Planned release strategy")
|
|
console.print(" ✅ Built HTML and PDF versions")
|
|
console.print(" ✅ Created GitHub release")
|
|
console.print(" ✅ Deployed to production")
|
|
console.print("\n[blue]🌐 Your book is now available at:[/blue]")
|
|
console.print(" 📖 Web: https://harvard-edge.github.io/cs249r_book")
|
|
console.print(" 📄 PDF: https://harvard-edge.github.io/cs249r_book/assets/Machine-Learning-Systems.pdf")
|
|
console.print("\n[blue]⏳ GitHub Actions will now:[/blue]")
|
|
console.print(" 🔄 Run quality checks")
|
|
console.print(" 🏗️ Build all formats")
|
|
console.print(" 🚀 Deploy to GitHub Pages")
|
|
console.print("\n[blue]📈 Monitor: https://github.com/harvard-edge/cs249r_book/actions[/blue]")
|
|
|
|
def _get_current_version(self):
|
|
"""Get current version from git tags"""
|
|
try:
|
|
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not tags:
|
|
return "0.0.0"
|
|
|
|
# Find the highest version
|
|
versions = [tag[1:] for tag in tags if tag.startswith('v')]
|
|
if not versions:
|
|
return "0.0.0"
|
|
|
|
return max(versions, key=lambda v: [int(x) for x in v.split('.')])
|
|
|
|
except Exception:
|
|
return "0.0.0"
|
|
|
|
def _analyze_changes(self):
|
|
"""Analyze recent changes to suggest version type"""
|
|
try:
|
|
# Get recent commits
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return "patch"
|
|
|
|
# Analyze commit messages for keywords
|
|
major_keywords = ['breaking', 'major', 'v2', 'overhaul', 'rewrite']
|
|
minor_keywords = ['feature', 'new', 'chapter', 'add', 'enhance']
|
|
|
|
commit_text = ' '.join(commits).lower()
|
|
|
|
if any(keyword in commit_text for keyword in major_keywords):
|
|
return "major"
|
|
elif any(keyword in commit_text for keyword in minor_keywords):
|
|
return "minor"
|
|
else:
|
|
return "patch"
|
|
|
|
except Exception:
|
|
return "patch"
|
|
|
|
def _increment_major_version(self, version):
|
|
"""Increment major version (1.0.0 → 2.0.0)"""
|
|
try:
|
|
major, minor, patch = map(int, version.split('.'))
|
|
return f"{major + 1}.0.0"
|
|
except:
|
|
return "1.0.0"
|
|
|
|
def _increment_minor_version(self, version):
|
|
"""Increment minor version (1.0.0 → 1.1.0)"""
|
|
try:
|
|
major, minor, patch = map(int, version.split('.'))
|
|
return f"{major}.{minor + 1}.0"
|
|
except:
|
|
return "1.0.0"
|
|
|
|
def _get_next_version(self):
|
|
"""Get the next version number based on existing tags"""
|
|
try:
|
|
# Get all version tags
|
|
result = subprocess.run(['git', 'tag', '--list', 'v*'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
tags = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
# Parse version numbers
|
|
versions = []
|
|
for tag in tags:
|
|
if tag.startswith('v'):
|
|
try:
|
|
version = tag[1:] # Remove 'v' prefix
|
|
versions.append(version)
|
|
except:
|
|
continue
|
|
|
|
if not versions:
|
|
return "1.0.0"
|
|
|
|
# Find the highest version
|
|
latest = max(versions, key=lambda v: [int(x) for x in v.split('.')])
|
|
major, minor, patch = map(int, latest.split('.'))
|
|
|
|
# Increment patch version
|
|
return f"{major}.{minor}.{patch + 1}"
|
|
|
|
except Exception as e:
|
|
console.print(f"[yellow]⚠️ Could not determine version: {e}[/yellow]")
|
|
return "1.0.0"
|
|
|
|
def _generate_manual_release_notes(self):
|
|
"""Generate manual release notes based on recent commits"""
|
|
try:
|
|
# Get recent commits since last tag
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return "📚 Book update with latest content and improvements."
|
|
|
|
# Format recent commits
|
|
notes = "## What's New\n\n"
|
|
for commit in commits[:10]: # Last 10 commits
|
|
if commit.strip():
|
|
notes += f"• {commit}\n"
|
|
|
|
notes += "\n## 📖 Book Updates\n"
|
|
notes += "• Updated content and chapters\n"
|
|
notes += "• Improved formatting and layout\n"
|
|
notes += "• Enhanced PDF generation\n"
|
|
|
|
return notes
|
|
|
|
except Exception as e:
|
|
return f"📚 Book update with latest content and improvements.\n\nError generating notes: {e}"
|
|
|
|
def _generate_ai_release_notes(self):
|
|
"""Generate AI-powered release notes using Ollama"""
|
|
try:
|
|
# Check if Ollama is available
|
|
subprocess.run(['ollama', '--version'], capture_output=True, check=True)
|
|
|
|
# Get recent commits
|
|
result = subprocess.run(['git', 'log', '--oneline', '--since=1.week.ago'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
commits = result.stdout.strip().split('\n') if result.stdout.strip() else []
|
|
|
|
if not commits:
|
|
return self._generate_manual_release_notes()
|
|
|
|
# Create prompt for AI
|
|
prompt = f"""Generate professional release notes for a machine learning systems book based on these recent commits:
|
|
|
|
{chr(10).join(commits[:15])}
|
|
|
|
Please create:
|
|
1. A brief summary of changes
|
|
2. Key improvements and updates
|
|
3. Technical details about the book content
|
|
4. Professional tone suitable for academic publishing
|
|
|
|
Format as markdown with clear sections."""
|
|
|
|
# Use Ollama to generate notes
|
|
ai_result = subprocess.run(['ollama', 'run', 'llama2', prompt],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
|
|
if ai_result.returncode == 0 and ai_result.stdout.strip():
|
|
return ai_result.stdout.strip()
|
|
else:
|
|
console.print("[yellow]⚠️ AI generation failed, using manual notes[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
console.print("[yellow]⚠️ Ollama not available, using manual notes[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
except Exception as e:
|
|
console.print(f"[yellow]⚠️ AI generation error: {e}[/yellow]")
|
|
return self._generate_manual_release_notes()
|
|
|
|
def show_about(self):
|
|
"""Display beautiful about screen with book binder theme"""
|
|
|
|
# Create a book-themed layout
|
|
about_panel = Panel(
|
|
"[bold blue]📚 Book Binder[/bold blue]\n"
|
|
"[dim]⚡ I compile ML systems knowledge[/dim]\n\n"
|
|
"[bold green]Version:[/bold green] 1.0.0\n"
|
|
"[bold green]Author:[/bold green] Prof. Vijay Janapa Reddi\n"
|
|
"[bold green]Repository:[/bold green] https://github.com/harvard-edge/cs249r_book\n\n"
|
|
"[bold yellow]🎯 Purpose:[/bold yellow]\n"
|
|
" • Bind chapters into beautiful books\n"
|
|
" • Preview content before publication\n"
|
|
" • Manage build configurations\n"
|
|
" • Publish to the world\n\n"
|
|
"[bold cyan]🛠️ Built with:[/bold cyan]\n"
|
|
" • Python 3.6+ (standard library only)\n"
|
|
" • Rich (beautiful terminal output)\n"
|
|
" • Quarto (academic publishing)\n"
|
|
" • Git (version control)\n\n"
|
|
"[bold magenta]📖 Book Information:[/bold magenta]\n"
|
|
" • Title: Machine Learning Systems\n"
|
|
" • Subtitle: Principles and Practices of Engineering AI Systems\n"
|
|
" • Author: Prof. Vijay Janapa Reddi\n"
|
|
" • Publisher: MIT Press (2026)\n"
|
|
" • License: CC BY-NC-SA 4.0\n\n"
|
|
"[bold blue]🌐 Live at:[/bold blue]\n"
|
|
" • Web: https://mlsysbook.ai\n"
|
|
" • PDF: https://mlsysbook.ai/pdf\n"
|
|
" • Ecosystem: https://mlsysbook.org\n\n"
|
|
"[dim]Made with ❤️ for aspiring AI engineers worldwide[/dim]",
|
|
title="📚 About Book Binder",
|
|
border_style="blue",
|
|
padding=(1, 2)
|
|
)
|
|
|
|
# Show system status
|
|
status_table = Table(show_header=True, header_style="bold green", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Details", style="dim")
|
|
|
|
# Check various components
|
|
status_table.add_row("📁 Book Directory", "✅ Found", str(self.book_dir))
|
|
status_table.add_row("🔗 Active Config", "✅ Active", str(self.active_config))
|
|
status_table.add_row("📚 Chapters", "✅ Available", f"{len(self.find_chapters())} chapters")
|
|
status_table.add_row("🏗️ Build Directory", "✅ Ready", str(self.build_dir))
|
|
|
|
# Check git status
|
|
try:
|
|
result = subprocess.run(['git', 'branch', '--show-current'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
current_branch = result.stdout.strip()
|
|
status_table.add_row("🌿 Git Branch", "✅ Active", current_branch)
|
|
except:
|
|
status_table.add_row("🌿 Git Branch", "❌ Error", "Could not determine")
|
|
|
|
# Check for uncommitted changes
|
|
try:
|
|
result = subprocess.run(['git', 'status', '--porcelain'],
|
|
capture_output=True, text=True, cwd=self.root_dir)
|
|
if result.stdout.strip():
|
|
status_table.add_row("📝 Git Status", "⚠️ Changes", "Uncommitted changes detected")
|
|
else:
|
|
status_table.add_row("📝 Git Status", "✅ Clean", "No uncommitted changes")
|
|
except:
|
|
status_table.add_row("📝 Git Status", "❌ Error", "Could not determine")
|
|
|
|
# Display everything
|
|
self.show_banner()
|
|
console.print(about_panel)
|
|
console.print(Panel(status_table, title="🔧 System Status", border_style="green"))
|
|
|
|
# Quick commands reminder
|
|
quick_commands = Table(show_header=True, header_style="bold blue", box=None)
|
|
quick_commands.add_column("Command", style="green", width=15)
|
|
quick_commands.add_column("Description", style="white")
|
|
|
|
quick_commands.add_row("./binder list", "See all chapters")
|
|
quick_commands.add_row("./binder status", "Show current config")
|
|
quick_commands.add_row("./binder help", "Show all commands")
|
|
quick_commands.add_row("./binder about", "Show this information")
|
|
|
|
console.print(Panel(quick_commands, title="🚀 Quick Commands", border_style="cyan"))
|
|
|
|
def show_help(self):
|
|
"""Display beautiful help screen"""
|
|
fast_table = Table(show_header=True, header_style="bold green", box=None)
|
|
fast_table.add_column("Command", style="green", width=35)
|
|
fast_table.add_column("Description", style="white", width=30)
|
|
fast_table.add_column("Example", style="dim", width=30)
|
|
|
|
fast_table.add_row("build <chapter[,ch2,...]|-> <format>", "Build chapter(s) or all", "./binder build intro html")
|
|
fast_table.add_row("preview <chapter>", "Build and preview chapter", "./binder preview ops")
|
|
|
|
full_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
full_table.add_column("Command", style="blue", width=35)
|
|
full_table.add_column("Description", style="white", width=30)
|
|
full_table.add_column("Example", style="dim", width=30)
|
|
|
|
full_table.add_row("build - <format>", "Build complete book", "./binder build - pdf")
|
|
full_table.add_row("preview-full [format]", "Preview complete book", "./binder preview-full")
|
|
full_table.add_row("publish", "Build and publish book", "./binder publish")
|
|
|
|
# Management Commands
|
|
mgmt_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
mgmt_table.add_column("Command", style="green", width=35)
|
|
mgmt_table.add_column("Description", style="white", width=30)
|
|
mgmt_table.add_column("Example", style="dim", width=30)
|
|
|
|
mgmt_table.add_row("clean", "Clean configs & artifacts", "./binder clean")
|
|
mgmt_table.add_row("check", "Check for build artifacts", "./binder check")
|
|
mgmt_table.add_row("switch <format>", "Switch config", "./binder switch pdf")
|
|
mgmt_table.add_row("status", "Show current status", "./binder status")
|
|
mgmt_table.add_row("list", "List chapters", "./binder list")
|
|
mgmt_table.add_row("hello", "Show welcome message", "./binder hello")
|
|
mgmt_table.add_row("setup", "Setup environment", "./binder setup")
|
|
mgmt_table.add_row("about", "Show about information", "./binder about")
|
|
mgmt_table.add_row("help", "Show this help", "./binder help")
|
|
|
|
console.print(mgmt_table)
|
|
|
|
# Shortcuts
|
|
shortcuts_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
shortcuts_table.add_column("Shortcut", style="magenta", width=10)
|
|
shortcuts_table.add_column("Full Command", style="white")
|
|
|
|
shortcuts_table.add_row("b", "build")
|
|
shortcuts_table.add_row("p", "preview")
|
|
shortcuts_table.add_row("pf", "preview-full")
|
|
shortcuts_table.add_row("c", "clean")
|
|
shortcuts_table.add_row("ch", "check")
|
|
shortcuts_table.add_row("s", "switch")
|
|
shortcuts_table.add_row("st", "status")
|
|
shortcuts_table.add_row("l", "list")
|
|
shortcuts_table.add_row("he", "hello")
|
|
shortcuts_table.add_row("se", "setup")
|
|
shortcuts_table.add_row("a", "about")
|
|
shortcuts_table.add_row("h", "help")
|
|
|
|
# Display everything
|
|
self.show_banner()
|
|
console.print(Panel(fast_table, title="⚡ Fast Chapter Commands", border_style="green"))
|
|
console.print(Panel(full_table, title="📚 Full Book Commands", border_style="blue"))
|
|
console.print(Panel(mgmt_table, title="🔧 Management", border_style="yellow"))
|
|
console.print(Panel(shortcuts_table, title="🚀 Shortcuts", border_style="cyan"))
|
|
|
|
# Pro Tips
|
|
examples = Text()
|
|
examples.append("🎯 Power User Examples:\n", style="bold magenta")
|
|
examples.append(" ./binder b intro,ml_systems html ", style="cyan")
|
|
examples.append("# Build multiple chapters\n", style="dim")
|
|
examples.append(" ./binder b - pdf ", style="cyan")
|
|
examples.append("# Build all chapters as PDF\n", style="dim")
|
|
examples.append(" ./binder c ", style="cyan")
|
|
examples.append("# Clean all artifacts\n", style="dim")
|
|
examples.append(" ./binder ch ", style="cyan")
|
|
examples.append("# Check for build artifacts\n", style="dim")
|
|
examples.append(" ./binder st ", style="cyan")
|
|
examples.append("# Quick status\n", style="dim")
|
|
examples.append("\n", style="dim")
|
|
|
|
console.print(Panel(examples, title="💡 Pro Tips", border_style="magenta"))
|
|
|
|
def show_hello(self):
|
|
"""Display welcome message and getting started guide"""
|
|
welcome_panel = Panel(
|
|
"[bold blue]🎉 Welcome to MLSysBook![/bold blue]\n\n"
|
|
"[green]I'm your Book Binder - I help you build and publish the Machine Learning Systems book.[/green]\n\n"
|
|
"[bold yellow]📚 What I can do:[/bold yellow]\n"
|
|
" • Build individual chapters or the entire book\n"
|
|
" • Preview content in your browser\n"
|
|
" • Publish to the world with smart versioning\n"
|
|
" • Manage build configurations\n"
|
|
" • Clean up artifacts automatically\n\n"
|
|
"[bold cyan]🚀 Getting Started:[/bold cyan]\n"
|
|
" 1. Run './binder setup' to configure your environment\n"
|
|
" 2. Try './binder list' to see available chapters\n"
|
|
" 3. Use './binder preview intro' to preview a chapter\n"
|
|
" 4. Run './binder help' for all commands\n\n"
|
|
"[bold magenta]💡 Quick Examples:[/bold magenta]\n"
|
|
" ./binder preview intro # Preview introduction chapter\n"
|
|
" ./binder build - html # Build complete HTML book\n"
|
|
" ./binder build - pdf # Build complete PDF book\n"
|
|
" ./binder publish # Publish to the world\n\n"
|
|
"[dim]Made with ❤️ for aspiring AI engineers worldwide[/dim]",
|
|
title="👋 Hello from Book Binder",
|
|
border_style="blue",
|
|
padding=(1, 2)
|
|
)
|
|
|
|
console.print(welcome_panel)
|
|
|
|
# Show current status
|
|
status_table = self._create_status_table()
|
|
|
|
# Add environment info
|
|
status_table.add_row("📁 Book Directory", "✅ Found", str(self.book_dir))
|
|
status_table.add_row("🔗 Active Config", "✅ Active", str(self.active_config))
|
|
status_table.add_row("📚 Chapters", "✅ Available", f"{len(self.find_chapters())} chapters")
|
|
|
|
# Add tool status
|
|
tools_to_check = [
|
|
('Quarto', ['quarto', '--version']),
|
|
('Python', ['python3', '--version']),
|
|
('Git', ['git', '--version'])
|
|
]
|
|
|
|
for tool_name, cmd in tools_to_check:
|
|
available, version = self._check_tool_availability(tool_name, cmd)
|
|
status = "✅ Available" if available else "❌ Not found"
|
|
status_table.add_row(tool_name, status.split()[0], status.split()[1] if len(status.split()) > 1 else "")
|
|
|
|
console.print(Panel(status_table, title="🔧 Environment Status", border_style="green"))
|
|
|
|
# Next steps
|
|
next_steps = Table(show_header=True, header_style="bold blue", box=None)
|
|
next_steps.add_column("Step", style="cyan", width=10)
|
|
next_steps.add_column("Command", style="green", width=25)
|
|
next_steps.add_column("Description", style="white")
|
|
|
|
next_steps.add_row("1", "./binder setup", "Configure your environment")
|
|
next_steps.add_row("2", "./binder list", "See available chapters")
|
|
next_steps.add_row("3", "./binder preview intro", "Preview a chapter")
|
|
next_steps.add_row("4", "./binder help", "Learn all commands")
|
|
|
|
console.print(Panel(next_steps, title="🎯 Next Steps", border_style="cyan"))
|
|
|
|
def setup_environment(self):
|
|
"""Setup the development environment"""
|
|
console.print("[bold blue]🔧 MLSysBook Environment Setup[/bold blue]")
|
|
console.print("[dim]I'll help you configure everything you need[/dim]\n")
|
|
|
|
# Step 1: Check current environment
|
|
step1_panel = Panel(
|
|
"[bold blue]🔍 Checking your environment...[/bold blue]",
|
|
title="📋 Step 1: Environment Check",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step1_panel)
|
|
self._check_environment()
|
|
|
|
# Step 2: System dependencies
|
|
step2_panel = Panel(
|
|
"[bold blue]🔧 Checking system dependencies...[/bold blue]",
|
|
title="📋 Step 2: System Dependencies",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step2_panel)
|
|
if not self._check_system_dependencies():
|
|
console.print("[red]❌ System dependency check failed[/red]")
|
|
return
|
|
|
|
# Step 3: Install/configure tools
|
|
step3_panel = Panel(
|
|
"[bold blue]📦 Installing tools and packages...[/bold blue]",
|
|
title="📋 Step 3: Tool Installation",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step3_panel)
|
|
self._install_tools()
|
|
|
|
# Step 4: Configure Git
|
|
step4_panel = Panel(
|
|
"[bold blue]🔧 Configuring Git...[/bold blue]",
|
|
title="📋 Step 4: Git Configuration",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step4_panel)
|
|
self._configure_git()
|
|
|
|
# Step 5: Environment preferences
|
|
step5_panel = Panel(
|
|
"[bold blue]⚙️ Configuring preferences...[/bold blue]",
|
|
title="📋 Step 5: Environment Preferences",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step5_panel)
|
|
self._configure_preferences()
|
|
|
|
# Step 6: Test setup
|
|
step6_panel = Panel(
|
|
"[bold blue]🧪 Testing setup...[/bold blue]",
|
|
title="📋 Step 6: Test Setup",
|
|
border_style="blue",
|
|
padding=(0, 1)
|
|
)
|
|
console.print(step6_panel)
|
|
self._test_setup()
|
|
|
|
console.print("\n[green]✅ Environment setup completed![/green]")
|
|
console.print("[blue]💡 Try './binder preview intro' to test your setup[/blue]")
|
|
|
|
def _get_tools_to_check(self):
|
|
"""Get list of tools to check/install"""
|
|
return [
|
|
('Quarto', ['quarto', '--version'], "https://quarto.org/docs/get-started/"),
|
|
('Python', ['python3', '--version'], "https://www.python.org/downloads/"),
|
|
('Git', ['git', '--version'], "https://git-scm.com/downloads"),
|
|
('GitHub CLI', ['gh', '--version'], "https://cli.github.com/"),
|
|
('Ollama', ['ollama', '--version'], "https://ollama.ai/")
|
|
]
|
|
|
|
def _check_tool_availability(self, tool_name, cmd):
|
|
"""Check if a tool is available and return version"""
|
|
try:
|
|
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
version = result.stdout.strip().split('\n')[0]
|
|
return True, version
|
|
except:
|
|
return False, "Not installed"
|
|
|
|
def _check_environment(self):
|
|
"""Check current environment status"""
|
|
console.print("[blue]🔍 Checking your environment...[/blue]")
|
|
|
|
status_table = Table(show_header=True, header_style="bold blue", box=None)
|
|
status_table.add_column("Tool", style="cyan", width=15)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Version", style="dim")
|
|
|
|
# Check various tools
|
|
tools_to_check = self._get_tools_to_check()
|
|
|
|
for tool_name, cmd, _ in tools_to_check:
|
|
available, version = self._check_tool_availability(tool_name, cmd)
|
|
status = "✅ Available" if available else "❌ Missing"
|
|
status_table.add_row(tool_name, status, version)
|
|
|
|
console.print(status_table)
|
|
|
|
def _install_tools(self):
|
|
"""Install missing tools and packages"""
|
|
console.print("[blue]📦 Installing tools and packages...[/blue]")
|
|
|
|
# Check what's missing and install automatically
|
|
missing_tools = []
|
|
tools_to_check = self._get_tools_to_check()
|
|
|
|
for tool_name, cmd, url in tools_to_check:
|
|
available, _ = self._check_tool_availability(tool_name, cmd)
|
|
if available:
|
|
console.print(f"[green]✅ {tool_name} already installed[/green]")
|
|
else:
|
|
missing_tools.append((tool_name, url))
|
|
|
|
if missing_tools:
|
|
console.print("[yellow]⚠️ Missing tools detected:[/yellow]")
|
|
for tool_name, url in missing_tools:
|
|
console.print(f" • {tool_name}: {url}")
|
|
|
|
console.print("\n[blue]🔧 Attempting automatic installation...[/blue]")
|
|
self._auto_install_tools(missing_tools)
|
|
else:
|
|
console.print("[green]✅ All required tools are available[/green]")
|
|
|
|
# Install Python packages
|
|
console.print("\n[blue]📦 Installing Python packages...[/blue]")
|
|
self._install_python_packages()
|
|
|
|
def _auto_install_tools(self, missing_tools):
|
|
"""Attempt to automatically install missing tools"""
|
|
import platform
|
|
system = platform.system().lower()
|
|
|
|
for tool_name, url in missing_tools:
|
|
console.print(f"[blue]🔧 Installing {tool_name}...[/blue]")
|
|
|
|
try:
|
|
self._install_tool(tool_name, system)
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install {tool_name}: {e}[/red]")
|
|
console.print(f"[yellow]💡 Please install manually: {url}[/yellow]")
|
|
|
|
def _get_installation_methods(self):
|
|
"""Get installation methods for different tools"""
|
|
return {
|
|
'Quarto': {
|
|
'darwin': {'method': 'brew', 'package': 'quarto'},
|
|
'linux': {'method': 'apt', 'package': 'quarto'}
|
|
},
|
|
'GitHub CLI': {
|
|
'darwin': {'method': 'brew', 'package': 'gh'},
|
|
'linux': {'method': 'apt', 'package': 'gh'}
|
|
},
|
|
'Ollama': {
|
|
'darwin': {'method': 'brew', 'package': 'ollama'},
|
|
'linux': {'method': 'curl', 'url': 'https://ollama.ai/install.sh'}
|
|
},
|
|
'Git': {
|
|
'darwin': {'method': 'brew', 'package': 'git'},
|
|
'linux': {'method': 'apt', 'package': 'git'}
|
|
}
|
|
}
|
|
|
|
def _install_tool(self, tool_name, system):
|
|
"""Install a tool using the appropriate method for the system"""
|
|
installation_methods = self._get_installation_methods()
|
|
|
|
if tool_name not in installation_methods:
|
|
raise Exception(f"Automatic {tool_name} installation not supported")
|
|
|
|
if system not in installation_methods[tool_name]:
|
|
raise Exception(f"Automatic {tool_name} installation not supported on this system")
|
|
|
|
method_info = installation_methods[tool_name][system]
|
|
method = method_info['method']
|
|
|
|
if method == 'brew':
|
|
package = method_info['package']
|
|
console.print(f"[blue]📦 Installing {tool_name} via Homebrew...[/blue]")
|
|
subprocess.run(['brew', 'install', package], check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
elif method == 'apt':
|
|
package = method_info['package']
|
|
console.print(f"[blue]📦 Installing {tool_name} via apt...[/blue]")
|
|
subprocess.run(['sudo', 'apt-get', 'update'], check=True)
|
|
subprocess.run(['sudo', 'apt-get', 'install', '-y', package], check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
elif method == 'curl':
|
|
url = method_info['url']
|
|
console.print(f"[blue]📦 Installing {tool_name} via curl...[/blue]")
|
|
subprocess.run(['curl', '-fsSL', url, '|', 'sh'], shell=True, check=True)
|
|
console.print(f"[green]✅ {tool_name} installed successfully[/green]")
|
|
|
|
def _install_python_packages(self):
|
|
"""Install required Python packages"""
|
|
console.print("[blue]🐍 Installing Python packages...[/blue]")
|
|
|
|
# Check if pip is available
|
|
try:
|
|
subprocess.run(['pip3', '--version'], capture_output=True, check=True)
|
|
except:
|
|
console.print("[red]❌ pip3 not found. Please install Python and pip first.[/red]")
|
|
return
|
|
|
|
# List of required packages (only external packages)
|
|
required_packages = [
|
|
'rich', # For beautiful terminal output
|
|
]
|
|
|
|
# Check what's already installed
|
|
installed_packages = []
|
|
try:
|
|
result = subprocess.run(['pip3', 'list'], capture_output=True, text=True, check=True)
|
|
installed_lines = result.stdout.split('\n')
|
|
for line in installed_lines:
|
|
if line.strip():
|
|
package_name = line.split()[0].lower()
|
|
installed_packages.append(package_name)
|
|
except:
|
|
console.print("[yellow]⚠️ Could not check installed packages[/yellow]")
|
|
|
|
# Check built-in modules
|
|
builtin_modules = ['pathlib', 'subprocess', 'os', 'sys', 'shutil', 'signal']
|
|
for module in builtin_modules:
|
|
try:
|
|
__import__(module)
|
|
console.print(f"[green]✅ {module} (built-in) available[/green]")
|
|
except ImportError:
|
|
console.print(f"[red]❌ {module} (built-in) not available[/red]")
|
|
|
|
# Install missing packages
|
|
for package in required_packages:
|
|
if package.lower() in installed_packages:
|
|
console.print(f"[green]✅ {package} already installed[/green]")
|
|
else:
|
|
try:
|
|
console.print(f"[blue]📦 Installing {package}...[/blue]")
|
|
subprocess.run(['pip3', 'install', package], check=True)
|
|
console.print(f"[green]✅ {package} installed successfully[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install {package}: {e}[/red]")
|
|
|
|
console.print("[green]✅ Python packages installation completed[/green]")
|
|
|
|
def _check_system_dependencies(self):
|
|
"""Check and install system dependencies"""
|
|
import platform
|
|
system = platform.system().lower()
|
|
|
|
console.print("[blue]🔧 Checking system dependencies...[/blue]")
|
|
|
|
if system == "darwin": # macOS
|
|
# Check for Homebrew
|
|
try:
|
|
subprocess.run(['brew', '--version'], capture_output=True, check=True)
|
|
console.print("[green]✅ Homebrew already installed[/green]")
|
|
except:
|
|
console.print("[blue]📦 Installing Homebrew...[/blue]")
|
|
try:
|
|
install_cmd = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'
|
|
subprocess.run(install_cmd, shell=True, check=True)
|
|
console.print("[green]✅ Homebrew installed successfully[/green]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Failed to install Homebrew: {e}[/red]")
|
|
console.print("[yellow]💡 Please install Homebrew manually: https://brew.sh[/yellow]")
|
|
return False
|
|
|
|
elif system == "linux":
|
|
# Check for apt (Ubuntu/Debian)
|
|
try:
|
|
subprocess.run(['apt-get', '--version'], capture_output=True, check=True)
|
|
console.print("[green]✅ apt package manager available[/green]")
|
|
except:
|
|
console.print("[yellow]⚠️ apt not found. Please install package manager manually.[/yellow]")
|
|
return False
|
|
|
|
return True
|
|
|
|
def _get_git_config(self, key):
|
|
"""Get Git configuration value"""
|
|
try:
|
|
result = subprocess.run(['git', 'config', key], capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
return result.stdout.strip()
|
|
except:
|
|
pass
|
|
return None
|
|
|
|
def _set_git_config(self, key, value):
|
|
"""Set Git configuration value"""
|
|
try:
|
|
subprocess.run(['git', 'config', '--global', key, value], check=True)
|
|
console.print(f"[green]✅ Set Git {key}: {value}[/green]")
|
|
return True
|
|
except subprocess.CalledProcessError as e:
|
|
console.print(f"[red]❌ Failed to set Git {key}: {e}[/red]")
|
|
return False
|
|
|
|
def _get_user_input_with_default(self, prompt, current_value=None):
|
|
"""Get user input with optional default value"""
|
|
if current_value:
|
|
console.print(f"[bold]{prompt} [{current_value}]: [/bold]", end="")
|
|
user_input = input().strip()
|
|
return user_input if user_input else current_value
|
|
else:
|
|
console.print(f"[bold]{prompt}: [/bold]", end="")
|
|
return input().strip()
|
|
|
|
def _configure_git(self):
|
|
"""Configure Git settings interactively"""
|
|
console.print("[blue]🔧 Configuring Git...[/blue]")
|
|
|
|
# Check current Git configuration
|
|
current_name = self._get_git_config('user.name')
|
|
current_email = self._get_git_config('user.email')
|
|
|
|
# Show current configuration
|
|
if current_name and current_email:
|
|
console.print(f"[green]✅ Current Git configuration:[/green]")
|
|
console.print(f" Name: {current_name}")
|
|
console.print(f" Email: {current_email}")
|
|
|
|
console.print("\n[blue]Would you like to update your Git configuration?[/blue]")
|
|
console.print("[bold]Update Git config? (y/N) [N]: [/bold]", end="")
|
|
update_choice = input().strip().lower()
|
|
if not update_choice:
|
|
update_choice = 'n'
|
|
|
|
if update_choice not in ['y', 'yes']:
|
|
console.print("[green]✅ Keeping current Git configuration[/green]")
|
|
return
|
|
else:
|
|
console.print("[yellow]⚠️ Git configuration incomplete[/yellow]")
|
|
|
|
# Get user information
|
|
console.print("\n[blue]📝 Please provide your information:[/blue]")
|
|
|
|
name_input = self._get_user_input_with_default("Full name", current_name)
|
|
email_input = self._get_user_input_with_default("Email", current_email)
|
|
github_username = self._get_user_input_with_default("GitHub username")
|
|
|
|
# Configure Git
|
|
success = True
|
|
if name_input:
|
|
success &= self._set_git_config('user.name', name_input)
|
|
|
|
if email_input:
|
|
success &= self._set_git_config('user.email', email_input)
|
|
|
|
if github_username:
|
|
success &= self._set_git_config('user.github', github_username)
|
|
|
|
if success:
|
|
console.print("[green]✅ Git configuration completed![/green]")
|
|
else:
|
|
console.print("[yellow]💡 You can configure Git manually:[/yellow]")
|
|
console.print(" git config --global user.name 'Your Name'")
|
|
console.print(" git config --global user.email 'your.email@example.com'")
|
|
|
|
def _get_preferences_config(self):
|
|
"""Get preferences configuration structure"""
|
|
return [
|
|
{
|
|
'key': 'binder.default-format',
|
|
'prompt': 'Default build format (html/pdf)',
|
|
'default': 'html',
|
|
'options': ['html', 'pdf'],
|
|
'description': '📚 Build Preferences:\n • html - Web format (faster, interactive)\n • pdf - Print format (slower, academic)'
|
|
},
|
|
{
|
|
'key': 'binder.auto-open',
|
|
'prompt': 'Auto-open browser after builds? (Y/n)',
|
|
'default': 'y',
|
|
'options': ['y', 'n'],
|
|
'description': '🌐 Browser Preferences:'
|
|
}
|
|
]
|
|
|
|
def _configure_preferences(self):
|
|
"""Configure user preferences and environment settings"""
|
|
console.print("[blue]⚙️ Configuring preferences...[/blue]")
|
|
|
|
preferences_config = self._get_preferences_config()
|
|
|
|
for pref in preferences_config:
|
|
console.print(f"\n[blue]{pref['description']}[/blue]")
|
|
|
|
# Get current value
|
|
current_value = self._get_git_config(pref['key'])
|
|
if current_value:
|
|
default_display = current_value
|
|
else:
|
|
default_display = pref['default']
|
|
|
|
# Get user input
|
|
user_input = self._get_user_input_with_default(
|
|
pref['prompt'],
|
|
default_display
|
|
)
|
|
|
|
# Validate input
|
|
if not user_input or user_input not in pref['options']:
|
|
user_input = pref['default']
|
|
|
|
# Convert y/n to true/false for boolean settings
|
|
if pref['key'] in ['binder.auto-open', 'binder.ai-default']:
|
|
setting_value = 'true' if user_input in ['y', 'yes'] else 'false'
|
|
else:
|
|
setting_value = user_input
|
|
|
|
# Store preference
|
|
if self._set_git_config(pref['key'], setting_value):
|
|
console.print(f"[green]✅ Set {pref['key']}: {setting_value}[/green]")
|
|
else:
|
|
console.print(f"[yellow]⚠️ Could not save {pref['key']} preference[/yellow]")
|
|
|
|
console.print("[green]✅ Preferences configured![/green]")
|
|
|
|
def get_user_preferences(self):
|
|
"""Get user preferences from Git config"""
|
|
preferences = {}
|
|
|
|
try:
|
|
# Get default format
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.default-format'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['default_format'] = result.stdout.strip()
|
|
else:
|
|
preferences['default_format'] = 'html'
|
|
|
|
# Get auto-open setting
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.auto-open'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['auto_open'] = result.stdout.strip() == 'true'
|
|
else:
|
|
preferences['auto_open'] = True
|
|
|
|
# Get AI default setting (only used in publish flow)
|
|
result = subprocess.run(['git', 'config', '--global', 'binder.ai-default'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['ai_default'] = result.stdout.strip() == 'true'
|
|
else:
|
|
# Default to True for AI features (only used in publish)
|
|
preferences['ai_default'] = True
|
|
|
|
# Get GitHub username
|
|
result = subprocess.run(['git', 'config', '--global', 'user.github'],
|
|
capture_output=True, text=True)
|
|
if result.returncode == 0:
|
|
preferences['github_username'] = result.stdout.strip()
|
|
else:
|
|
preferences['github_username'] = None
|
|
|
|
except Exception:
|
|
# Return defaults if anything fails
|
|
preferences = {
|
|
'default_format': 'html',
|
|
'auto_open': True,
|
|
'ai_default': True,
|
|
'github_username': None
|
|
}
|
|
|
|
return preferences
|
|
|
|
def _create_status_table(self):
|
|
"""Create a standard status table"""
|
|
status_table = Table(show_header=True, header_style="bold green", box=None)
|
|
status_table.add_column("Component", style="cyan", width=20)
|
|
status_table.add_column("Status", style="white", width=15)
|
|
status_table.add_column("Details", style="dim")
|
|
return status_table
|
|
|
|
def _test_setup(self):
|
|
"""Test the setup by building a simple chapter"""
|
|
console.print("[blue]🧪 Testing setup...[/blue]")
|
|
|
|
# Try to build a simple chapter
|
|
try:
|
|
console.print("[blue]📚 Testing chapter build...[/blue]")
|
|
success = self.build("intro", "html")
|
|
if success:
|
|
console.print("[green]✅ Build test successful![/green]")
|
|
else:
|
|
console.print("[yellow]⚠️ Build test failed - check your Quarto installation[/yellow]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Test failed: {e}[/red]")
|
|
|
|
def main():
|
|
binder = BookBinder()
|
|
|
|
if len(sys.argv) < 2:
|
|
binder.show_help()
|
|
return
|
|
|
|
command = sys.argv[1].lower()
|
|
|
|
# Enhanced command mapping with aliases
|
|
command_aliases = {
|
|
'b': 'build',
|
|
'p': 'preview',
|
|
'pf': 'preview-full',
|
|
'c': 'clean',
|
|
'ch': 'check',
|
|
's': 'switch',
|
|
'st': 'status',
|
|
'l': 'list',
|
|
'he': 'hello',
|
|
'se': 'setup',
|
|
'h': 'help'
|
|
}
|
|
|
|
# Apply aliases
|
|
if command in command_aliases:
|
|
command = command_aliases[command]
|
|
|
|
# Handle commands
|
|
try:
|
|
if command == "build":
|
|
if len(sys.argv) < 4:
|
|
console.print("[red]❌ Usage: ./binder build <chapter[,chapter2,...]|all|-> <format>[/red]")
|
|
console.print("[dim]Examples: ./binder build - html, ./binder build intro pdf[/dim]")
|
|
return
|
|
binder.show_symlink_status()
|
|
chapters = sys.argv[2]
|
|
format_type = sys.argv[3]
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
|
|
if chapters == "-" or chapters == "all":
|
|
# Build all chapters
|
|
binder.build_full(format_type)
|
|
else:
|
|
# Build specific chapters
|
|
binder.build(chapters, format_type)
|
|
|
|
elif command == "preview":
|
|
if len(sys.argv) < 3:
|
|
console.print("[red]❌ Usage: ./binder preview <chapter>[/red]")
|
|
return
|
|
binder.show_symlink_status()
|
|
chapter = sys.argv[2]
|
|
binder.preview(chapter)
|
|
|
|
elif command == "build-full":
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
binder.build_full(format_type)
|
|
|
|
elif command == "preview-full":
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2] if len(sys.argv) > 2 else "html"
|
|
if format_type not in ["html", "pdf"]:
|
|
console.print("[red]❌ Format must be 'html' or 'pdf'[/red]")
|
|
return
|
|
binder.preview_full(format_type)
|
|
|
|
elif command == "clean":
|
|
binder.show_symlink_status()
|
|
binder.clean()
|
|
|
|
elif command == "check":
|
|
success = binder.check_artifacts()
|
|
if not success:
|
|
sys.exit(1)
|
|
|
|
elif command == "switch":
|
|
if len(sys.argv) < 3:
|
|
console.print("[red]❌ Usage: ./binder switch <html|pdf>[/red]")
|
|
return
|
|
binder.show_symlink_status()
|
|
format_type = sys.argv[2]
|
|
binder.switch(format_type)
|
|
|
|
elif command == "status":
|
|
binder.show_status()
|
|
|
|
elif command == "list":
|
|
binder.show_chapters()
|
|
|
|
elif command == "publish":
|
|
binder.show_symlink_status()
|
|
binder.publish()
|
|
|
|
elif command == "about":
|
|
binder.show_about()
|
|
|
|
elif command == "help":
|
|
binder.show_help()
|
|
|
|
elif command == "hello":
|
|
binder.show_hello()
|
|
|
|
elif command == "setup":
|
|
binder.setup_environment()
|
|
|
|
else:
|
|
console.print(f"[red]❌ Unknown command: {command}[/red]")
|
|
console.print("[yellow]💡 Use './binder help' to see available commands[/yellow]")
|
|
|
|
except KeyboardInterrupt:
|
|
console.print("\n[yellow]👋 Goodbye![/yellow]")
|
|
except Exception as e:
|
|
console.print(f"[red]❌ Error: {e}[/red]")
|
|
|
|
if __name__ == "__main__":
|
|
main() |