loki-mode 6.53.0 → 6.55.0
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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/bin/postinstall.js +29 -0
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +11 -2
- package/web-app/Dockerfile +59 -0
- package/web-app/alembic.ini +43 -0
- package/web-app/auth.py +249 -0
- package/web-app/crypto.py +83 -0
- package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
- package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
- package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
- package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
- package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
- package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
- package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
- package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
- package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
- package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
- package/web-app/dist/assets/{Badge-CnWBUi7C.js → Badge-BDr4DPCT.js} +1 -1
- package/web-app/dist/assets/{Button-5ThWFbkO.js → Button-WBFGRnUr.js} +1 -1
- package/web-app/dist/assets/{Card-CcTmaOCN.js → Card-DzOT34Rr.js} +1 -1
- package/web-app/dist/assets/{HomePage-Dx4Ae0hu.js → HomePage-B8kMCXMB.js} +1 -1
- package/web-app/dist/assets/{LoginPage-CRffqZNo.js → LoginPage-D9lCyiqM.js} +1 -1
- package/web-app/dist/assets/{NotFoundPage-B1QZ92yR.js → NotFoundPage-DzeZ0uQ6.js} +1 -1
- package/web-app/dist/assets/{ProjectPage-BVnDGxXk.js → ProjectPage-C-k0iy0i.js} +14 -14
- package/web-app/dist/assets/{ProjectsPage-2Fi6cKB-.js → ProjectsPage-jys_pHzp.js} +1 -1
- package/web-app/dist/assets/{SettingsPage-DOzGoyLv.js → SettingsPage-Cz_RXr82.js} +1 -1
- package/web-app/dist/assets/{TemplatesPage-B-f1Gfbg.js → TemplatesPage-COnhb_Wq.js} +1 -1
- package/web-app/dist/assets/{TerminalOutput-DrKIbiB8.js → TerminalOutput-CmdEXHHd.js} +1 -1
- package/web-app/dist/assets/{arrow-left-CFG0TEkb.js → arrow-left-DAZzI0L-.js} +1 -1
- package/web-app/dist/assets/{clock-C-GPrW5k.js → clock-BHGf6zSk.js} +1 -1
- package/web-app/dist/assets/{external-link-ujbkNBY4.js → external-link-DLYjfP9j.js} +1 -1
- package/web-app/dist/assets/{index-B8gGcUMo.js → index-B8Eg1YHL.js} +2 -2
- package/web-app/dist/index.html +1 -1
- package/web-app/docker-compose.purple-lab.yml +76 -0
- package/web-app/migrations/env.py +103 -0
- package/web-app/migrations/script.py.mako +25 -0
- package/web-app/migrations/versions/.gitkeep +0 -0
- package/web-app/migrations/versions/001_initial_schema.py +118 -0
- package/web-app/models.py +140 -0
- package/web-app/requirements.txt +27 -0
- package/web-app/server.py +158 -22
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Purple Lab database models.
|
|
2
|
+
|
|
3
|
+
Provides SQLAlchemy models for multi-user cloud deployment.
|
|
4
|
+
When no DATABASE_URL is configured, the system falls back to
|
|
5
|
+
file-based storage (local development mode).
|
|
6
|
+
"""
|
|
7
|
+
import os
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from sqlalchemy import (
|
|
12
|
+
Boolean,
|
|
13
|
+
Column,
|
|
14
|
+
DateTime,
|
|
15
|
+
ForeignKey,
|
|
16
|
+
Integer,
|
|
17
|
+
JSON,
|
|
18
|
+
String,
|
|
19
|
+
Text,
|
|
20
|
+
)
|
|
21
|
+
from sqlalchemy.dialects.postgresql import UUID
|
|
22
|
+
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
23
|
+
from sqlalchemy.orm import DeclarativeBase, relationship
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Base(DeclarativeBase):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class User(Base):
|
|
31
|
+
__tablename__ = "users"
|
|
32
|
+
|
|
33
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
34
|
+
email = Column(String(255), unique=True, nullable=False, index=True)
|
|
35
|
+
name = Column(String(255))
|
|
36
|
+
avatar_url = Column(String(500))
|
|
37
|
+
provider = Column(String(50)) # "github", "google", "email"
|
|
38
|
+
provider_id = Column(String(255)) # External provider user ID
|
|
39
|
+
password_hash = Column(String(255), nullable=True) # For email/password auth
|
|
40
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
41
|
+
last_login = Column(DateTime)
|
|
42
|
+
is_active = Column(Boolean, default=True)
|
|
43
|
+
|
|
44
|
+
sessions = relationship("Session", back_populates="user")
|
|
45
|
+
projects = relationship("Project", back_populates="user")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Project(Base):
|
|
49
|
+
__tablename__ = "projects"
|
|
50
|
+
|
|
51
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
52
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
53
|
+
name = Column(String(255), nullable=False)
|
|
54
|
+
description = Column(Text)
|
|
55
|
+
project_dir = Column(String(500), nullable=False)
|
|
56
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
57
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
58
|
+
|
|
59
|
+
user = relationship("User", back_populates="projects")
|
|
60
|
+
sessions = relationship("Session", back_populates="project")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class Session(Base):
|
|
64
|
+
__tablename__ = "sessions"
|
|
65
|
+
|
|
66
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
67
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
68
|
+
project_id = Column(UUID(as_uuid=True), ForeignKey("projects.id"), nullable=True)
|
|
69
|
+
prd_content = Column(Text)
|
|
70
|
+
provider = Column(String(50), default="claude")
|
|
71
|
+
mode = Column(String(50), default="standard")
|
|
72
|
+
status = Column(String(50), default="created") # created, running, paused, completed, failed
|
|
73
|
+
started_at = Column(DateTime, default=datetime.utcnow)
|
|
74
|
+
ended_at = Column(DateTime, nullable=True)
|
|
75
|
+
metadata_json = Column(JSON, default=dict)
|
|
76
|
+
|
|
77
|
+
user = relationship("User", back_populates="sessions")
|
|
78
|
+
project = relationship("Project", back_populates="sessions")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Secret(Base):
|
|
82
|
+
__tablename__ = "secrets"
|
|
83
|
+
|
|
84
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
85
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
|
86
|
+
key = Column(String(255), nullable=False)
|
|
87
|
+
encrypted_value = Column(Text, nullable=False) # Fernet encrypted
|
|
88
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
89
|
+
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AuditLog(Base):
|
|
93
|
+
__tablename__ = "audit_log"
|
|
94
|
+
|
|
95
|
+
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
96
|
+
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True)
|
|
97
|
+
action = Column(String(100), nullable=False) # "session.start", "file.save", etc.
|
|
98
|
+
resource_type = Column(String(50)) # "session", "file", "secret"
|
|
99
|
+
resource_id = Column(String(255))
|
|
100
|
+
details = Column(JSON, default=dict)
|
|
101
|
+
ip_address = Column(String(45))
|
|
102
|
+
created_at = Column(DateTime, default=datetime.utcnow)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
# Database connection state
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
DATABASE_URL: str | None = None
|
|
110
|
+
engine = None
|
|
111
|
+
async_session_factory: async_sessionmaker | None = None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def init_db(database_url: str | None = None) -> bool:
|
|
115
|
+
"""Initialize database connection. Returns False if no URL configured (file-based fallback)."""
|
|
116
|
+
global DATABASE_URL, engine, async_session_factory
|
|
117
|
+
|
|
118
|
+
url = database_url or os.environ.get("DATABASE_URL")
|
|
119
|
+
if not url:
|
|
120
|
+
return False # No database configured, use file-based fallback
|
|
121
|
+
|
|
122
|
+
DATABASE_URL = url
|
|
123
|
+
engine = create_async_engine(url, echo=False)
|
|
124
|
+
async_session_factory = async_sessionmaker(
|
|
125
|
+
engine, class_=AsyncSession, expire_on_commit=False
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async with engine.begin() as conn:
|
|
129
|
+
await conn.run_sync(Base.metadata.create_all)
|
|
130
|
+
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def get_db():
|
|
135
|
+
"""Get database session. Yields None if no database configured."""
|
|
136
|
+
if async_session_factory is None:
|
|
137
|
+
yield None
|
|
138
|
+
return
|
|
139
|
+
async with async_session_factory() as session:
|
|
140
|
+
yield session
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Core -- required for server.py to start
|
|
2
|
+
fastapi>=0.100.0
|
|
3
|
+
uvicorn[standard]>=0.20.0
|
|
4
|
+
pydantic>=2.0.0
|
|
5
|
+
httpx>=0.24.0
|
|
6
|
+
|
|
7
|
+
# Terminal -- PTY-based interactive terminal
|
|
8
|
+
pexpect>=4.8.0
|
|
9
|
+
|
|
10
|
+
# File watcher -- live file-change notifications via WebSocket
|
|
11
|
+
watchdog>=3.0.0
|
|
12
|
+
|
|
13
|
+
# Database (optional) -- only needed for multi-user cloud deployment.
|
|
14
|
+
# Without DATABASE_URL set, the server uses file-based storage.
|
|
15
|
+
sqlalchemy[asyncio]>=2.0.0
|
|
16
|
+
asyncpg>=0.28.0
|
|
17
|
+
aiosqlite>=0.19.0
|
|
18
|
+
alembic>=1.12.0
|
|
19
|
+
|
|
20
|
+
# Auth (optional) -- only needed when DATABASE_URL is configured.
|
|
21
|
+
# Imports are guarded by try/except so the server starts without them.
|
|
22
|
+
python-jose[cryptography]>=3.3.0
|
|
23
|
+
passlib[bcrypt]>=1.7.4
|
|
24
|
+
|
|
25
|
+
# Encryption (optional) -- Fernet encryption for user secrets.
|
|
26
|
+
# Needed only for the /api/secrets endpoints in cloud mode.
|
|
27
|
+
cryptography>=41.0.0
|
package/web-app/server.py
CHANGED
|
@@ -482,6 +482,60 @@ class DevServerManager:
|
|
|
482
482
|
|
|
483
483
|
def __init__(self) -> None:
|
|
484
484
|
self.servers: Dict[str, dict] = {}
|
|
485
|
+
self._portless_available: Optional[bool] = None
|
|
486
|
+
self._portless_proxy_started = False
|
|
487
|
+
|
|
488
|
+
def _has_portless(self) -> bool:
|
|
489
|
+
"""Check if portless CLI is installed (cached)."""
|
|
490
|
+
if self._portless_available is None:
|
|
491
|
+
try:
|
|
492
|
+
subprocess.run(
|
|
493
|
+
["portless", "--version"],
|
|
494
|
+
capture_output=True, timeout=5,
|
|
495
|
+
)
|
|
496
|
+
self._portless_available = True
|
|
497
|
+
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
498
|
+
self._portless_available = False
|
|
499
|
+
return self._portless_available
|
|
500
|
+
|
|
501
|
+
def _portless_app_name(self, session_id: str) -> str:
|
|
502
|
+
"""Generate a deterministic short app name from session_id."""
|
|
503
|
+
clean = re.sub(r"[^a-zA-Z0-9]", "", session_id)
|
|
504
|
+
return f"lab-{clean[:6].lower()}"
|
|
505
|
+
|
|
506
|
+
def _ensure_portless_proxy(self) -> bool:
|
|
507
|
+
"""Start the portless proxy if not already running.
|
|
508
|
+
|
|
509
|
+
Returns True if the proxy is available, False otherwise.
|
|
510
|
+
"""
|
|
511
|
+
if self._portless_proxy_started:
|
|
512
|
+
return True
|
|
513
|
+
# Check if port 1355 is already listening
|
|
514
|
+
import socket
|
|
515
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
516
|
+
s.settimeout(1)
|
|
517
|
+
try:
|
|
518
|
+
s.connect(("127.0.0.1", 1355))
|
|
519
|
+
s.close()
|
|
520
|
+
self._portless_proxy_started = True
|
|
521
|
+
return True
|
|
522
|
+
except (ConnectionRefusedError, OSError):
|
|
523
|
+
s.close()
|
|
524
|
+
# Try to start the proxy
|
|
525
|
+
try:
|
|
526
|
+
subprocess.Popen(
|
|
527
|
+
["portless", "proxy", "start"],
|
|
528
|
+
stdout=subprocess.DEVNULL,
|
|
529
|
+
stderr=subprocess.DEVNULL,
|
|
530
|
+
stdin=subprocess.DEVNULL,
|
|
531
|
+
)
|
|
532
|
+
# Give it a moment to start
|
|
533
|
+
import time as _time
|
|
534
|
+
_time.sleep(1)
|
|
535
|
+
self._portless_proxy_started = True
|
|
536
|
+
return True
|
|
537
|
+
except (FileNotFoundError, OSError):
|
|
538
|
+
return False
|
|
485
539
|
|
|
486
540
|
async def detect_dev_command(self, project_dir: str) -> Optional[dict]:
|
|
487
541
|
"""Detect the dev command from project files."""
|
|
@@ -557,7 +611,20 @@ class DevServerManager:
|
|
|
557
611
|
if session_id in self.servers:
|
|
558
612
|
await self.stop(session_id)
|
|
559
613
|
|
|
614
|
+
# Try to detect dev command -- check root first, then subdirectories
|
|
560
615
|
detected = await self.detect_dev_command(project_dir)
|
|
616
|
+
actual_dir = project_dir
|
|
617
|
+
if not detected:
|
|
618
|
+
# Check immediate subdirectories for a project with package.json
|
|
619
|
+
root = Path(project_dir)
|
|
620
|
+
if root.is_dir():
|
|
621
|
+
for subdir in sorted(root.iterdir()):
|
|
622
|
+
if subdir.is_dir() and not subdir.name.startswith('.'):
|
|
623
|
+
sub_detected = await self.detect_dev_command(str(subdir))
|
|
624
|
+
if sub_detected:
|
|
625
|
+
detected = sub_detected
|
|
626
|
+
actual_dir = str(subdir)
|
|
627
|
+
break
|
|
561
628
|
if not command and not detected:
|
|
562
629
|
return {"status": "error", "message": "No dev command detected. Provide one explicitly."}
|
|
563
630
|
|
|
@@ -565,18 +632,39 @@ class DevServerManager:
|
|
|
565
632
|
expected_port = detected["expected_port"] if detected else 3000
|
|
566
633
|
framework = detected["framework"] if detected else "unknown"
|
|
567
634
|
|
|
635
|
+
# Auto-install dependencies if needed
|
|
636
|
+
actual_path = Path(actual_dir)
|
|
637
|
+
needs_npm = (actual_path / "package.json").exists() and not (actual_path / "node_modules").exists()
|
|
638
|
+
needs_pip = (actual_path / "requirements.txt").exists() and not (actual_path / "venv").exists()
|
|
639
|
+
if needs_npm:
|
|
640
|
+
# Prepend npm install to the command
|
|
641
|
+
cmd_str = f"npm install && {cmd_str}"
|
|
642
|
+
if needs_pip:
|
|
643
|
+
cmd_str = f"pip install -r requirements.txt && {cmd_str}"
|
|
644
|
+
|
|
645
|
+
# Check if portless is available and proxy is running
|
|
646
|
+
use_portless = False
|
|
647
|
+
portless_app_name = None
|
|
648
|
+
if self._has_portless() and self._ensure_portless_proxy():
|
|
649
|
+
portless_app_name = self._portless_app_name(session_id)
|
|
650
|
+
use_portless = True
|
|
651
|
+
# Wrap the command with portless
|
|
652
|
+
effective_cmd = f"portless {portless_app_name} {cmd_str}"
|
|
653
|
+
else:
|
|
654
|
+
effective_cmd = cmd_str
|
|
655
|
+
|
|
568
656
|
build_env = {**os.environ}
|
|
569
657
|
build_env.update(_load_secrets())
|
|
570
658
|
|
|
571
659
|
try:
|
|
572
660
|
proc = subprocess.Popen(
|
|
573
|
-
|
|
661
|
+
effective_cmd,
|
|
574
662
|
shell=True,
|
|
575
663
|
stdout=subprocess.PIPE,
|
|
576
664
|
stderr=subprocess.STDOUT,
|
|
577
665
|
stdin=subprocess.DEVNULL,
|
|
578
666
|
text=True,
|
|
579
|
-
cwd=
|
|
667
|
+
cwd=actual_dir,
|
|
580
668
|
env=build_env,
|
|
581
669
|
**({"start_new_session": True} if sys.platform != "win32"
|
|
582
670
|
else {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}),
|
|
@@ -588,12 +676,15 @@ class DevServerManager:
|
|
|
588
676
|
"process": proc,
|
|
589
677
|
"port": None,
|
|
590
678
|
"expected_port": expected_port,
|
|
591
|
-
"command":
|
|
679
|
+
"command": effective_cmd,
|
|
680
|
+
"original_command": cmd_str,
|
|
592
681
|
"framework": framework,
|
|
593
682
|
"status": "starting",
|
|
594
683
|
"pid": proc.pid,
|
|
595
684
|
"project_dir": project_dir,
|
|
596
685
|
"output_lines": [],
|
|
686
|
+
"use_portless": use_portless,
|
|
687
|
+
"portless_app_name": portless_app_name,
|
|
597
688
|
}
|
|
598
689
|
self.servers[session_id] = server_info
|
|
599
690
|
|
|
@@ -612,16 +703,22 @@ class DevServerManager:
|
|
|
612
703
|
"output": info["output_lines"][-10:] if info["output_lines"] else [],
|
|
613
704
|
}
|
|
614
705
|
if info["port"] is not None:
|
|
615
|
-
|
|
706
|
+
# For portless, also verify the portless proxy can reach the app
|
|
707
|
+
check_port = info["port"]
|
|
708
|
+
health_ok = await self._health_check(check_port)
|
|
616
709
|
if health_ok:
|
|
617
710
|
info["status"] = "running"
|
|
618
|
-
|
|
711
|
+
result = {
|
|
619
712
|
"status": "running",
|
|
620
713
|
"port": info["port"],
|
|
621
|
-
"command":
|
|
714
|
+
"command": effective_cmd,
|
|
622
715
|
"pid": proc.pid,
|
|
623
716
|
"url": f"/proxy/{session_id}/",
|
|
624
717
|
}
|
|
718
|
+
if use_portless and portless_app_name:
|
|
719
|
+
result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
|
|
720
|
+
result["port"] = 1355
|
|
721
|
+
return result
|
|
625
722
|
|
|
626
723
|
if proc.poll() is not None:
|
|
627
724
|
server_info["status"] = "error"
|
|
@@ -635,24 +732,32 @@ class DevServerManager:
|
|
|
635
732
|
if health_ok:
|
|
636
733
|
server_info["port"] = expected_port
|
|
637
734
|
server_info["status"] = "running"
|
|
638
|
-
|
|
735
|
+
result = {
|
|
639
736
|
"status": "running",
|
|
640
737
|
"port": expected_port,
|
|
641
|
-
"command":
|
|
738
|
+
"command": effective_cmd,
|
|
642
739
|
"pid": proc.pid,
|
|
643
740
|
"url": f"/proxy/{session_id}/",
|
|
644
741
|
}
|
|
742
|
+
if use_portless and portless_app_name:
|
|
743
|
+
result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
|
|
744
|
+
result["port"] = 1355
|
|
745
|
+
return result
|
|
645
746
|
|
|
646
747
|
server_info["status"] = "starting"
|
|
647
748
|
server_info["port"] = expected_port
|
|
648
|
-
|
|
749
|
+
result = {
|
|
649
750
|
"status": "starting",
|
|
650
751
|
"message": "Server started but port not yet confirmed",
|
|
651
752
|
"port": expected_port,
|
|
652
|
-
"command":
|
|
753
|
+
"command": effective_cmd,
|
|
653
754
|
"pid": proc.pid,
|
|
654
755
|
"url": f"/proxy/{session_id}/",
|
|
655
756
|
}
|
|
757
|
+
if use_portless and portless_app_name:
|
|
758
|
+
result["portless_url"] = f"http://{portless_app_name}.localhost:1355/"
|
|
759
|
+
result["port"] = 1355
|
|
760
|
+
return result
|
|
656
761
|
|
|
657
762
|
async def _monitor_output(self, session_id: str) -> None:
|
|
658
763
|
"""Background task: read dev server stdout and detect port."""
|
|
@@ -766,7 +871,7 @@ class DevServerManager:
|
|
|
766
871
|
if not alive and info["status"] in ("running", "starting"):
|
|
767
872
|
info["status"] = "error"
|
|
768
873
|
|
|
769
|
-
|
|
874
|
+
result = {
|
|
770
875
|
"running": alive and info["status"] == "running",
|
|
771
876
|
"status": info["status"],
|
|
772
877
|
"port": info.get("port"),
|
|
@@ -776,6 +881,12 @@ class DevServerManager:
|
|
|
776
881
|
"framework": info.get("framework"),
|
|
777
882
|
"output": info.get("output_lines", [])[-20:],
|
|
778
883
|
}
|
|
884
|
+
if info.get("use_portless") and info.get("portless_app_name"):
|
|
885
|
+
app_name = info["portless_app_name"]
|
|
886
|
+
result["portless_url"] = f"http://{app_name}.localhost:1355/"
|
|
887
|
+
if alive:
|
|
888
|
+
result["port"] = 1355
|
|
889
|
+
return result
|
|
779
890
|
|
|
780
891
|
async def stop_all(self) -> None:
|
|
781
892
|
"""Stop all dev servers (used on shutdown)."""
|
|
@@ -2308,6 +2419,14 @@ async def chat_session(session_id: str, req: ChatRequest) -> JSONResponse:
|
|
|
2308
2419
|
break
|
|
2309
2420
|
# Strip ANSI escape codes for clean display
|
|
2310
2421
|
clean = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw_line.rstrip("\n"))
|
|
2422
|
+
# Filter out noisy tool-use output lines from Claude
|
|
2423
|
+
stripped = clean.strip()
|
|
2424
|
+
if stripped in ("[Tool: Read]", "[Tool: Bash]", "[Tool: Write]",
|
|
2425
|
+
"[Tool: Edit]", "[Tool: Grep]", "[Tool: Glob]",
|
|
2426
|
+
"[Result]", "[Thinking]"):
|
|
2427
|
+
continue
|
|
2428
|
+
if not stripped:
|
|
2429
|
+
continue
|
|
2311
2430
|
task.output_lines.append(clean)
|
|
2312
2431
|
proc.stdout.close()
|
|
2313
2432
|
|
|
@@ -2630,7 +2749,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2630
2749
|
info["type"] = "web-app"
|
|
2631
2750
|
info["entry_file"] = "index.html"
|
|
2632
2751
|
info["preview_url"] = f"/api/sessions/{session_id}/preview/index.html"
|
|
2633
|
-
info["dev_command"] =
|
|
2752
|
+
info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
|
|
2634
2753
|
info["description"] = "Web application -- serves HTML/CSS/JS"
|
|
2635
2754
|
elif is_express or (has_package_json and ("start" in pkg_scripts or "dev" in pkg_scripts) and not has_index_html):
|
|
2636
2755
|
# API/server project
|
|
@@ -2642,7 +2761,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2642
2761
|
port = int(port_match.group(1))
|
|
2643
2762
|
info["type"] = "api"
|
|
2644
2763
|
info["port"] = port
|
|
2645
|
-
info["dev_command"] =
|
|
2764
|
+
info["dev_command"] = "npm run dev" if "dev" in pkg_scripts else "npm start" if "start" in pkg_scripts else None
|
|
2646
2765
|
info["description"] = f"API server -- runs on port {port}"
|
|
2647
2766
|
# Check for swagger/openapi
|
|
2648
2767
|
for swagger_path in ["swagger.json", "openapi.json", "docs", "api-docs"]:
|
|
@@ -2661,7 +2780,7 @@ async def get_preview_info(session_id: str) -> JSONResponse:
|
|
|
2661
2780
|
info["description"] = "Static site -- serves HTML directly"
|
|
2662
2781
|
elif has_package_json and "test" in pkg_scripts:
|
|
2663
2782
|
info["type"] = "library"
|
|
2664
|
-
info["dev_command"] =
|
|
2783
|
+
info["dev_command"] = "npm test"
|
|
2665
2784
|
info["description"] = "Library/package -- run tests to verify"
|
|
2666
2785
|
elif has_go_mod:
|
|
2667
2786
|
info["type"] = "go-app"
|
|
@@ -2708,8 +2827,12 @@ async def start_devserver(session_id: str, req: DevServerStartRequest) -> JSONRe
|
|
|
2708
2827
|
target = _find_session_dir(session_id)
|
|
2709
2828
|
if target is None:
|
|
2710
2829
|
return JSONResponse(status_code=404, content={"error": "Session not found"})
|
|
2711
|
-
|
|
2712
|
-
|
|
2830
|
+
try:
|
|
2831
|
+
result = await dev_server_manager.start(session_id, str(target), req.command)
|
|
2832
|
+
except Exception as e:
|
|
2833
|
+
logger.error("Dev server start failed: %s", e)
|
|
2834
|
+
result = {"status": "error", "message": str(e)}
|
|
2835
|
+
status_code = 200 if result.get("status") != "error" else 400
|
|
2713
2836
|
return JSONResponse(content=result, status_code=status_code)
|
|
2714
2837
|
|
|
2715
2838
|
|
|
@@ -2753,8 +2876,17 @@ async def proxy_to_devserver(session_id: str, path: str, request: Request):
|
|
|
2753
2876
|
status_code=503,
|
|
2754
2877
|
)
|
|
2755
2878
|
|
|
2756
|
-
|
|
2757
|
-
|
|
2879
|
+
# Determine target: portless URL or direct port
|
|
2880
|
+
if server.get("use_portless") and server.get("portless_app_name"):
|
|
2881
|
+
app_name = server["portless_app_name"]
|
|
2882
|
+
target_host = f"{app_name}.localhost"
|
|
2883
|
+
target_port = 1355
|
|
2884
|
+
target_url = f"http://{target_host}:{target_port}/{path}"
|
|
2885
|
+
else:
|
|
2886
|
+
target_port = server["port"]
|
|
2887
|
+
target_host = f"127.0.0.1:{target_port}"
|
|
2888
|
+
target_url = f"http://127.0.0.1:{target_port}/{path}"
|
|
2889
|
+
|
|
2758
2890
|
if request.url.query:
|
|
2759
2891
|
target_url += f"?{request.url.query}"
|
|
2760
2892
|
|
|
@@ -2765,7 +2897,7 @@ async def proxy_to_devserver(session_id: str, path: str, request: Request):
|
|
|
2765
2897
|
k: v for k, v in request.headers.items()
|
|
2766
2898
|
if k.lower() not in skip_headers
|
|
2767
2899
|
}
|
|
2768
|
-
fwd_headers["host"] =
|
|
2900
|
+
fwd_headers["host"] = target_host
|
|
2769
2901
|
|
|
2770
2902
|
body = await request.body()
|
|
2771
2903
|
|
|
@@ -2837,9 +2969,13 @@ async def proxy_websocket(websocket: WebSocket, session_id: str, path: str):
|
|
|
2837
2969
|
await websocket.close(code=1008, reason="Dev server not running")
|
|
2838
2970
|
return
|
|
2839
2971
|
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2972
|
+
# Determine WebSocket target: portless or direct
|
|
2973
|
+
if server.get("use_portless") and server.get("portless_app_name"):
|
|
2974
|
+
app_name = server["portless_app_name"]
|
|
2975
|
+
ws_url = f"ws://{app_name}.localhost:1355/{path}"
|
|
2976
|
+
else:
|
|
2977
|
+
target_port = server["port"]
|
|
2978
|
+
ws_url = f"ws://127.0.0.1:{target_port}/{path}"
|
|
2843
2979
|
|
|
2844
2980
|
await websocket.accept()
|
|
2845
2981
|
|