ralphx 0.3.4__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
ralphx/api/routes/resources.py
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"""Resource management API routes."""
|
|
2
2
|
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
3
7
|
from typing import Optional
|
|
4
8
|
|
|
5
9
|
from fastapi import APIRouter, HTTPException, Query, status
|
|
@@ -114,6 +118,9 @@ async def list_resources(
|
|
|
114
118
|
"""List all resources for a project."""
|
|
115
119
|
manager, project, resource_manager = get_project_and_resources(slug)
|
|
116
120
|
|
|
121
|
+
# Auto-sync from filesystem to ensure files on disk are reflected
|
|
122
|
+
resource_manager.sync_from_filesystem()
|
|
123
|
+
|
|
117
124
|
# Validate resource_type if provided
|
|
118
125
|
rt = None
|
|
119
126
|
if resource_type:
|
|
@@ -197,12 +204,18 @@ async def create_resource(slug: str, data: ResourceCreate):
|
|
|
197
204
|
ip = validate_injection_position(data.injection_position)
|
|
198
205
|
|
|
199
206
|
# Create resource (creates file and db entry)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
207
|
+
try:
|
|
208
|
+
resource = resource_manager.create_resource(
|
|
209
|
+
name=data.name,
|
|
210
|
+
resource_type=rt,
|
|
211
|
+
content=data.content,
|
|
212
|
+
injection_position=ip,
|
|
213
|
+
)
|
|
214
|
+
except ValueError as e:
|
|
215
|
+
raise HTTPException(
|
|
216
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
217
|
+
detail=str(e),
|
|
218
|
+
)
|
|
206
219
|
|
|
207
220
|
return ResourceResponse(
|
|
208
221
|
id=resource["id"],
|
|
@@ -329,3 +342,512 @@ async def list_resource_types():
|
|
|
329
342
|
for p in InjectionPosition
|
|
330
343
|
],
|
|
331
344
|
}
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
# =============================================================================
|
|
348
|
+
# Design Doc File Operations (for interactive design_doc steps)
|
|
349
|
+
# These work directly with files, not database resources
|
|
350
|
+
# =============================================================================
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class DesignDocFileInfo(BaseModel):
|
|
354
|
+
"""Info about a design doc file."""
|
|
355
|
+
|
|
356
|
+
path: str # Relative path like "design_doc/RCM_DESIGN.md"
|
|
357
|
+
name: str # Just the filename
|
|
358
|
+
size: int # Bytes
|
|
359
|
+
modified: str # ISO timestamp
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
class DesignDocFileContent(BaseModel):
|
|
363
|
+
"""Design doc file with content."""
|
|
364
|
+
|
|
365
|
+
path: str
|
|
366
|
+
name: str
|
|
367
|
+
size: int
|
|
368
|
+
modified: str
|
|
369
|
+
content: str
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
class DesignDocBackup(BaseModel):
|
|
373
|
+
"""Info about a backup file."""
|
|
374
|
+
|
|
375
|
+
path: str
|
|
376
|
+
name: str
|
|
377
|
+
size: int
|
|
378
|
+
created: str # ISO timestamp
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class SaveDesignDocRequest(BaseModel):
|
|
382
|
+
"""Request to save design doc content."""
|
|
383
|
+
|
|
384
|
+
content: str
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
class SaveDesignDocResponse(BaseModel):
|
|
388
|
+
"""Response after saving design doc."""
|
|
389
|
+
|
|
390
|
+
path: str
|
|
391
|
+
backup_path: Optional[str] = None
|
|
392
|
+
size: int
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def get_project_path(slug: str) -> Path:
|
|
396
|
+
"""Get project path or raise 404."""
|
|
397
|
+
manager = get_manager()
|
|
398
|
+
project = manager.get_project(slug)
|
|
399
|
+
if not project:
|
|
400
|
+
raise HTTPException(
|
|
401
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
402
|
+
detail=f"Project not found: {slug}",
|
|
403
|
+
)
|
|
404
|
+
return Path(project.path)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _validate_safe_filename(name: str, label: str = "file name") -> None:
|
|
408
|
+
"""Validate that a filename is safe (no path traversal, null bytes, etc.).
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
name: The filename to validate.
|
|
412
|
+
label: Human-readable label for error messages.
|
|
413
|
+
|
|
414
|
+
Raises:
|
|
415
|
+
HTTPException: If the filename is unsafe.
|
|
416
|
+
"""
|
|
417
|
+
if not name or ".." in name or "/" in name or "\\" in name or "\0" in name or name.startswith("."):
|
|
418
|
+
raise HTTPException(
|
|
419
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
420
|
+
detail=f"Invalid {label}",
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _validate_path_containment(file_path: Path, allowed_root: Path) -> None:
|
|
425
|
+
"""Verify that a resolved path stays within the allowed root directory.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
file_path: The path to validate.
|
|
429
|
+
allowed_root: The directory the path must stay within.
|
|
430
|
+
|
|
431
|
+
Raises:
|
|
432
|
+
HTTPException: If the path escapes the allowed root.
|
|
433
|
+
"""
|
|
434
|
+
resolved = file_path.resolve()
|
|
435
|
+
root_resolved = allowed_root.resolve()
|
|
436
|
+
if not resolved.is_relative_to(root_resolved):
|
|
437
|
+
raise HTTPException(
|
|
438
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
439
|
+
detail="Invalid file path",
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def get_design_doc_folder(project_path: Path) -> Path:
|
|
444
|
+
"""Get the design_doc resources folder."""
|
|
445
|
+
return project_path / ".ralphx" / "resources" / "design_doc"
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def get_backups_folder(project_path: Path) -> Path:
|
|
449
|
+
"""Get the backups folder, creating if needed."""
|
|
450
|
+
backups = project_path / ".ralphx" / "resources" / "design_doc" / "backups"
|
|
451
|
+
backups.mkdir(parents=True, exist_ok=True)
|
|
452
|
+
return backups
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@router.get("/{slug}/design-doc-files", response_model=list[DesignDocFileInfo])
|
|
456
|
+
async def list_design_doc_files(slug: str):
|
|
457
|
+
"""List all .md files in the design_doc resources folder."""
|
|
458
|
+
project_path = get_project_path(slug)
|
|
459
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
460
|
+
|
|
461
|
+
if not design_doc_folder.exists():
|
|
462
|
+
return []
|
|
463
|
+
|
|
464
|
+
files = []
|
|
465
|
+
for file_path in design_doc_folder.glob("*.md"):
|
|
466
|
+
if file_path.is_file():
|
|
467
|
+
stat = file_path.stat()
|
|
468
|
+
files.append(
|
|
469
|
+
DesignDocFileInfo(
|
|
470
|
+
path=f"design_doc/{file_path.name}",
|
|
471
|
+
name=file_path.name,
|
|
472
|
+
size=stat.st_size,
|
|
473
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
474
|
+
)
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Sort by modified time, newest first
|
|
478
|
+
files.sort(key=lambda f: f.modified, reverse=True)
|
|
479
|
+
return files
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
@router.get("/{slug}/design-doc-files/{file_name}", response_model=DesignDocFileContent)
|
|
483
|
+
async def get_design_doc_file(slug: str, file_name: str):
|
|
484
|
+
"""Read a design doc file's content."""
|
|
485
|
+
project_path = get_project_path(slug)
|
|
486
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
487
|
+
|
|
488
|
+
# Security: prevent path traversal and null bytes
|
|
489
|
+
_validate_safe_filename(file_name, "file name")
|
|
490
|
+
|
|
491
|
+
file_path = design_doc_folder / file_name
|
|
492
|
+
_validate_path_containment(file_path, design_doc_folder)
|
|
493
|
+
|
|
494
|
+
if not file_path.exists() or not file_path.is_file():
|
|
495
|
+
raise HTTPException(
|
|
496
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
497
|
+
detail=f"File not found: {file_name}",
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
stat = file_path.stat()
|
|
501
|
+
content = file_path.read_text(encoding="utf-8")
|
|
502
|
+
|
|
503
|
+
return DesignDocFileContent(
|
|
504
|
+
path=f"design_doc/{file_name}",
|
|
505
|
+
name=file_name,
|
|
506
|
+
size=stat.st_size,
|
|
507
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
508
|
+
content=content,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
@router.post("/{slug}/design-doc-files/{file_name}/save", response_model=SaveDesignDocResponse)
|
|
513
|
+
async def save_design_doc_file(slug: str, file_name: str, data: SaveDesignDocRequest):
|
|
514
|
+
"""Save design doc file with automatic backup.
|
|
515
|
+
|
|
516
|
+
If the file exists, creates a timestamped backup in the backups/ folder
|
|
517
|
+
before overwriting.
|
|
518
|
+
"""
|
|
519
|
+
project_path = get_project_path(slug)
|
|
520
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
521
|
+
|
|
522
|
+
# Security: prevent path traversal and null bytes
|
|
523
|
+
_validate_safe_filename(file_name, "file name")
|
|
524
|
+
|
|
525
|
+
# Ensure folder exists
|
|
526
|
+
design_doc_folder.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
|
|
528
|
+
file_path = design_doc_folder / file_name
|
|
529
|
+
_validate_path_containment(file_path, design_doc_folder)
|
|
530
|
+
backup_path_str = None
|
|
531
|
+
|
|
532
|
+
# Create backup if file exists
|
|
533
|
+
if file_path.exists():
|
|
534
|
+
backups_folder = get_backups_folder(project_path)
|
|
535
|
+
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
536
|
+
backup_name = f"{file_path.stem}.{timestamp}{file_path.suffix}"
|
|
537
|
+
backup_path = backups_folder / backup_name
|
|
538
|
+
|
|
539
|
+
shutil.copy2(file_path, backup_path)
|
|
540
|
+
backup_path_str = f"design_doc/backups/{backup_name}"
|
|
541
|
+
|
|
542
|
+
# Keep only last 10 backups for this file
|
|
543
|
+
_cleanup_old_backups(backups_folder, file_path.stem, max_backups=10)
|
|
544
|
+
|
|
545
|
+
# Write new content
|
|
546
|
+
file_path.write_text(data.content, encoding="utf-8")
|
|
547
|
+
|
|
548
|
+
return SaveDesignDocResponse(
|
|
549
|
+
path=f"design_doc/{file_name}",
|
|
550
|
+
backup_path=backup_path_str,
|
|
551
|
+
size=len(data.content.encode("utf-8")),
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
@router.post("/{slug}/design-doc-files/create", response_model=DesignDocFileInfo)
|
|
556
|
+
async def create_design_doc_file(slug: str, name: str = Query(..., description="File name without .md extension")):
|
|
557
|
+
"""Create a new empty design doc file."""
|
|
558
|
+
project_path = get_project_path(slug)
|
|
559
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
560
|
+
|
|
561
|
+
# Sanitize name
|
|
562
|
+
safe_name = "".join(c for c in name if c.isalnum() or c in "-_").strip()
|
|
563
|
+
if not safe_name:
|
|
564
|
+
raise HTTPException(
|
|
565
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
566
|
+
detail="Invalid file name",
|
|
567
|
+
)
|
|
568
|
+
|
|
569
|
+
file_name = f"{safe_name}.md"
|
|
570
|
+
file_path = design_doc_folder / file_name
|
|
571
|
+
|
|
572
|
+
if file_path.exists():
|
|
573
|
+
raise HTTPException(
|
|
574
|
+
status_code=status.HTTP_409_CONFLICT,
|
|
575
|
+
detail=f"File already exists: {file_name}",
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
# Create folder and file
|
|
579
|
+
design_doc_folder.mkdir(parents=True, exist_ok=True)
|
|
580
|
+
file_path.write_text(f"# {name}\n\n", encoding="utf-8")
|
|
581
|
+
|
|
582
|
+
stat = file_path.stat()
|
|
583
|
+
return DesignDocFileInfo(
|
|
584
|
+
path=f"design_doc/{file_name}",
|
|
585
|
+
name=file_name,
|
|
586
|
+
size=stat.st_size,
|
|
587
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
588
|
+
)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@router.get("/{slug}/design-doc-files/{file_name}/backups", response_model=list[DesignDocBackup])
|
|
592
|
+
async def list_design_doc_backups(slug: str, file_name: str):
|
|
593
|
+
"""List backups for a design doc file."""
|
|
594
|
+
# Security: prevent path traversal and null bytes
|
|
595
|
+
_validate_safe_filename(file_name, "file name")
|
|
596
|
+
|
|
597
|
+
project_path = get_project_path(slug)
|
|
598
|
+
backups_folder = get_backups_folder(project_path)
|
|
599
|
+
|
|
600
|
+
# Get the stem (filename without extension)
|
|
601
|
+
# Sanitize stem to prevent glob injection (only allow alphanumeric, hyphens, underscores)
|
|
602
|
+
stem = Path(file_name).stem
|
|
603
|
+
safe_stem = "".join(c for c in stem if c.isalnum() or c in "-_")
|
|
604
|
+
if not safe_stem:
|
|
605
|
+
return []
|
|
606
|
+
|
|
607
|
+
backups = []
|
|
608
|
+
for backup_path in backups_folder.glob(f"{safe_stem}.*.md"):
|
|
609
|
+
if backup_path.is_file():
|
|
610
|
+
stat = backup_path.stat()
|
|
611
|
+
backups.append(
|
|
612
|
+
DesignDocBackup(
|
|
613
|
+
path=f"design_doc/backups/{backup_path.name}",
|
|
614
|
+
name=backup_path.name,
|
|
615
|
+
size=stat.st_size,
|
|
616
|
+
created=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
617
|
+
)
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
# Sort by created time, newest first
|
|
621
|
+
backups.sort(key=lambda b: b.created, reverse=True)
|
|
622
|
+
return backups
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@router.get("/{slug}/design-doc-files/backups/{backup_name}", response_model=DesignDocFileContent)
|
|
626
|
+
async def get_design_doc_backup(slug: str, backup_name: str):
|
|
627
|
+
"""Read a backup file's content."""
|
|
628
|
+
project_path = get_project_path(slug)
|
|
629
|
+
backups_folder = get_backups_folder(project_path)
|
|
630
|
+
|
|
631
|
+
# Security: prevent path traversal and null bytes
|
|
632
|
+
_validate_safe_filename(backup_name, "backup name")
|
|
633
|
+
|
|
634
|
+
backup_path = backups_folder / backup_name
|
|
635
|
+
_validate_path_containment(backup_path, backups_folder)
|
|
636
|
+
|
|
637
|
+
if not backup_path.exists() or not backup_path.is_file():
|
|
638
|
+
raise HTTPException(
|
|
639
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
640
|
+
detail=f"Backup not found: {backup_name}",
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
stat = backup_path.stat()
|
|
644
|
+
content = backup_path.read_text(encoding="utf-8")
|
|
645
|
+
|
|
646
|
+
return DesignDocFileContent(
|
|
647
|
+
path=f"design_doc/backups/{backup_name}",
|
|
648
|
+
name=backup_name,
|
|
649
|
+
size=stat.st_size,
|
|
650
|
+
modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
|
|
651
|
+
content=content,
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _cleanup_old_backups(backups_folder: Path, stem: str, max_backups: int = 10):
|
|
656
|
+
"""Remove old backups, keeping only the most recent max_backups."""
|
|
657
|
+
backups = list(backups_folder.glob(f"{stem}.*.md"))
|
|
658
|
+
if len(backups) <= max_backups:
|
|
659
|
+
return
|
|
660
|
+
|
|
661
|
+
# Sort by modification time, oldest first
|
|
662
|
+
backups.sort(key=lambda p: p.stat().st_mtime)
|
|
663
|
+
|
|
664
|
+
# Remove oldest backups
|
|
665
|
+
for backup in backups[: len(backups) - max_backups]:
|
|
666
|
+
backup.unlink()
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
# =============================================================================
|
|
670
|
+
# Design Doc Diff and Restore Operations
|
|
671
|
+
# =============================================================================
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
class DiffLine(BaseModel):
|
|
675
|
+
"""A single line in a diff."""
|
|
676
|
+
|
|
677
|
+
line: str
|
|
678
|
+
type: str # 'add', 'remove', 'context'
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class DiffResult(BaseModel):
|
|
682
|
+
"""Result of comparing two versions."""
|
|
683
|
+
|
|
684
|
+
left_path: str
|
|
685
|
+
right_path: str
|
|
686
|
+
left_size: int
|
|
687
|
+
right_size: int
|
|
688
|
+
chars_added: int
|
|
689
|
+
chars_removed: int
|
|
690
|
+
diff_html: str # Unified diff with syntax highlighting
|
|
691
|
+
diff_lines: list[DiffLine] # Structured diff for custom rendering
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class RestoreBackupRequest(BaseModel):
|
|
695
|
+
"""Request to restore a backup."""
|
|
696
|
+
|
|
697
|
+
backup_name: str
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
class RestoreBackupResponse(BaseModel):
|
|
701
|
+
"""Response after restoring a backup."""
|
|
702
|
+
|
|
703
|
+
success: bool
|
|
704
|
+
backup_created: Optional[str] = None # New backup of current state
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@router.get("/{slug}/design-doc-files/{file_name}/diff", response_model=DiffResult)
|
|
708
|
+
async def diff_design_doc_versions(
|
|
709
|
+
slug: str,
|
|
710
|
+
file_name: str,
|
|
711
|
+
left: str = Query(..., description="Left version: 'current' or backup filename"),
|
|
712
|
+
right: str = Query(..., description="Right version: 'current' or backup filename"),
|
|
713
|
+
):
|
|
714
|
+
"""Compare two versions of a design document.
|
|
715
|
+
|
|
716
|
+
Returns a unified diff with change statistics.
|
|
717
|
+
"""
|
|
718
|
+
import difflib
|
|
719
|
+
|
|
720
|
+
project_path = get_project_path(slug)
|
|
721
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
722
|
+
backups_folder = get_backups_folder(project_path)
|
|
723
|
+
|
|
724
|
+
# Security: prevent path traversal and null bytes
|
|
725
|
+
_validate_safe_filename(file_name, "file name")
|
|
726
|
+
if left != "current":
|
|
727
|
+
_validate_safe_filename(left, "left version")
|
|
728
|
+
if right != "current":
|
|
729
|
+
_validate_safe_filename(right, "right version")
|
|
730
|
+
|
|
731
|
+
# Resolve left version
|
|
732
|
+
if left == "current":
|
|
733
|
+
left_path = design_doc_folder / file_name
|
|
734
|
+
left_label = "current"
|
|
735
|
+
else:
|
|
736
|
+
left_path = backups_folder / left
|
|
737
|
+
_validate_path_containment(left_path, backups_folder)
|
|
738
|
+
left_label = left
|
|
739
|
+
|
|
740
|
+
# Resolve right version
|
|
741
|
+
if right == "current":
|
|
742
|
+
right_path = design_doc_folder / file_name
|
|
743
|
+
right_label = "current"
|
|
744
|
+
else:
|
|
745
|
+
right_path = backups_folder / right
|
|
746
|
+
_validate_path_containment(right_path, backups_folder)
|
|
747
|
+
right_label = right
|
|
748
|
+
|
|
749
|
+
if not left_path.exists():
|
|
750
|
+
raise HTTPException(
|
|
751
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
752
|
+
detail=f"Left version not found: {left}",
|
|
753
|
+
)
|
|
754
|
+
if not right_path.exists():
|
|
755
|
+
raise HTTPException(
|
|
756
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
757
|
+
detail=f"Right version not found: {right}",
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
left_content = left_path.read_text(encoding="utf-8")
|
|
761
|
+
right_content = right_path.read_text(encoding="utf-8")
|
|
762
|
+
|
|
763
|
+
# Generate unified diff
|
|
764
|
+
diff = list(
|
|
765
|
+
difflib.unified_diff(
|
|
766
|
+
left_content.splitlines(keepends=True),
|
|
767
|
+
right_content.splitlines(keepends=True),
|
|
768
|
+
fromfile=left_label,
|
|
769
|
+
tofile=right_label,
|
|
770
|
+
)
|
|
771
|
+
)
|
|
772
|
+
|
|
773
|
+
# Calculate stats
|
|
774
|
+
chars_added = sum(
|
|
775
|
+
len(line) - 1
|
|
776
|
+
for line in diff
|
|
777
|
+
if line.startswith("+") and not line.startswith("+++")
|
|
778
|
+
)
|
|
779
|
+
chars_removed = sum(
|
|
780
|
+
len(line) - 1
|
|
781
|
+
for line in diff
|
|
782
|
+
if line.startswith("-") and not line.startswith("---")
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
# Build structured diff lines
|
|
786
|
+
diff_lines = []
|
|
787
|
+
for line in diff:
|
|
788
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
789
|
+
diff_lines.append(DiffLine(line=line.rstrip("\n"), type="add"))
|
|
790
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
791
|
+
diff_lines.append(DiffLine(line=line.rstrip("\n"), type="remove"))
|
|
792
|
+
elif line.startswith("@@"):
|
|
793
|
+
diff_lines.append(DiffLine(line=line.rstrip("\n"), type="hunk"))
|
|
794
|
+
elif not line.startswith("+++") and not line.startswith("---"):
|
|
795
|
+
diff_lines.append(DiffLine(line=line.rstrip("\n"), type="context"))
|
|
796
|
+
|
|
797
|
+
return DiffResult(
|
|
798
|
+
left_path=left_label,
|
|
799
|
+
right_path=right_label,
|
|
800
|
+
left_size=len(left_content),
|
|
801
|
+
right_size=len(right_content),
|
|
802
|
+
chars_added=chars_added,
|
|
803
|
+
chars_removed=chars_removed,
|
|
804
|
+
diff_html="".join(diff),
|
|
805
|
+
diff_lines=diff_lines,
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@router.post(
|
|
810
|
+
"/{slug}/design-doc-files/{file_name}/restore",
|
|
811
|
+
response_model=RestoreBackupResponse,
|
|
812
|
+
)
|
|
813
|
+
async def restore_design_doc_backup(
|
|
814
|
+
slug: str,
|
|
815
|
+
file_name: str,
|
|
816
|
+
request: RestoreBackupRequest,
|
|
817
|
+
):
|
|
818
|
+
"""Restore a design doc from a backup.
|
|
819
|
+
|
|
820
|
+
Creates a backup of current state before restoring.
|
|
821
|
+
"""
|
|
822
|
+
project_path = get_project_path(slug)
|
|
823
|
+
design_doc_folder = get_design_doc_folder(project_path)
|
|
824
|
+
backups_folder = get_backups_folder(project_path)
|
|
825
|
+
|
|
826
|
+
# Security: prevent path traversal and null bytes
|
|
827
|
+
_validate_safe_filename(file_name, "file name")
|
|
828
|
+
_validate_safe_filename(request.backup_name, "backup name")
|
|
829
|
+
|
|
830
|
+
backup_path = backups_folder / request.backup_name
|
|
831
|
+
current_path = design_doc_folder / file_name
|
|
832
|
+
_validate_path_containment(backup_path, backups_folder)
|
|
833
|
+
_validate_path_containment(current_path, design_doc_folder)
|
|
834
|
+
|
|
835
|
+
if not backup_path.exists():
|
|
836
|
+
raise HTTPException(
|
|
837
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
838
|
+
detail=f"Backup not found: {request.backup_name}",
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
# Backup current if it exists
|
|
842
|
+
new_backup = None
|
|
843
|
+
if current_path.exists():
|
|
844
|
+
timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
|
|
845
|
+
new_backup = f"{Path(file_name).stem}.{timestamp}.md"
|
|
846
|
+
backups_folder.mkdir(parents=True, exist_ok=True)
|
|
847
|
+
shutil.copy2(current_path, backups_folder / new_backup)
|
|
848
|
+
|
|
849
|
+
# Restore from backup
|
|
850
|
+
design_doc_folder.mkdir(parents=True, exist_ok=True)
|
|
851
|
+
shutil.copy2(backup_path, current_path)
|
|
852
|
+
|
|
853
|
+
return RestoreBackupResponse(success=True, backup_created=new_backup)
|